diff --git a/.gitattributes b/.gitattributes index 8157af1df2c1..de50942d6f2d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,7 +19,6 @@ # .version, which names the immutable remote install dir. A CRLF checkout makes a # Windows-built client disagree with a mac/Linux-built one on the same release, # so one host ends up with two relay trees (#17886 review). -/config/relay-assets/** text eol=lf # Pin the bytes so a patch reads and diffs identically on every host. It is NOT # what makes the hash right: pnpm hashes a patch LF-normalized, so a CRLF checkout # cannot change it. Believing otherwise put a hand-computed raw digest in the diff --git a/.github/actions/prepare-windows-cli-launcher/action.yml b/.github/actions/prepare-windows-cli-launcher/action.yml new file mode 100644 index 000000000000..8e7ae74f48cf --- /dev/null +++ b/.github/actions/prepare-windows-cli-launcher/action.yml @@ -0,0 +1,39 @@ +name: Prepare the orcad Windows CLI launcher +description: Compiles native/windows-cli-launcher for each requested arch into .build/windows-cli-launcher//orca.exe. + +inputs: + arches: + description: Space-separated Windows architectures (x64, arm64). + required: true + +runs: + using: composite + steps: + - name: Cache the Windows CLI launcher builds + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + .build/windows-cli-launcher + native/windows-cli-launcher/target + ~/.cargo/registry + key: orcad-windows-cli-launcher-${{ runner.arch }}-${{ inputs.arches }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs', 'resources/build/icon.ico', 'package.json') }} + + # Why explicit: a runner image without cargo would otherwise fail inside the orcad build. + - name: Build the launcher for each architecture + if: runner.os == 'Windows' + shell: pwsh + env: + ARCHES: ${{ inputs.arches }} + run: | + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + rustup toolchain install stable --profile minimal + rustup default stable + } + $triples = @{ x64 = 'x86_64-pc-windows-msvc'; arm64 = 'aarch64-pc-windows-msvc' } + foreach ($arch in ($env:ARCHES -split ' ' | Where-Object { $_ })) { + rustup target add $triples[$arch] + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node config/scripts/build-windows-cli-launcher.mjs --arch $arch --output ".build/windows-cli-launcher/$arch/orca.exe" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 49e85608d0e2..ac274e55734e 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -24,7 +24,6 @@ on: - 'pnpm-workspace.yaml' - '.npmrc' - '.pnpmfile.cjs' - - 'config/tsconfig*.json' - 'config/scripts/ensure-native-runtime.mjs' - 'config/scripts/rebuild-native-deps.mjs' - 'config/scripts/node-pty-job-ownership.cjs' @@ -86,19 +85,6 @@ jobs: - uses: ./.github/actions/prepare-git-compatibility - - name: Cache TypeScript incremental state - id: typecheck-cache - uses: actions/cache@v5 - with: - path: config/*.tsbuildinfo - key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }} - restore-keys: | - tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- - - - name: Refresh TypeScript state for this commit - if: steps.typecheck-cache.outputs.cache-hit != 'true' - run: pnpm run typecheck - warm-linux-arm: runs-on: ubuntu-24.04-arm timeout-minutes: 10 diff --git a/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml b/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml index 29e68510b214..4ca3e3567926 100644 --- a/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml +++ b/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml @@ -544,7 +544,6 @@ jobs: --capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \ --capacity-cell-id "${cell_id}" \ --director-cells-json "${desired_cells_json}" \ - --min-instances 0 \ --prune-revisions true \ --release-id "bootstrap-${cell_id}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}")" echo "${director_result}" diff --git a/.github/workflows/cloud-deploy-relay-production-director.yml b/.github/workflows/cloud-deploy-relay-production-director.yml index 07e1e74ec3c2..6094f25ccafc 100644 --- a/.github/workflows/cloud-deploy-relay-production-director.yml +++ b/.github/workflows/cloud-deploy-relay-production-director.yml @@ -18,6 +18,17 @@ on: required: true default: preserve type: string + reserve-placement: + description: Preserve step-5 reserve placement, or set it; turning it off is refused while any cell is reserve + required: true + default: preserve + type: choice + options: [preserve, 'off', dry-run, 'on'] + shadow-seat-feed-cells: + description: Preserve the step-3 shadow seat-feed cells, or set all or a comma list of cell ids; dropping a reserve cell is refused + required: true + default: preserve + type: string prune-incompatible-revisions: description: Retain only the newly verified serving and rollback revisions required: true @@ -68,6 +79,8 @@ jobs: IMAGE_DIGEST: ${{ inputs.image-digest }} REGIONAL_PLACEMENT_MODE: ${{ inputs.regional-placement-mode }} REGION_CORRECTION_COHORT_PERCENT: ${{ inputs.region-correction-cohort-percent }} + RESERVE_PLACEMENT: ${{ inputs.reserve-placement }} + SHADOW_SEAT_FEED_CELLS: ${{ inputs.shadow-seat-feed-cells }} PRUNE_INCOMPATIBLE_REVISIONS: ${{ inputs.prune-incompatible-revisions }} # Floor the served revision must keep, matching relay_min_instances in # environments/production.tfvars. This gate only fails a bad deploy; Terraform @@ -228,7 +241,9 @@ jobs: --prune-revisions "${PRUNE_INCOMPATIBLE_REVISIONS}" \ --release-id "${RELEASE_ID}" \ --regional-placement-secret-version "${target_version}" \ - --region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" + --region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" \ + --reserve-placement "${RESERVE_PLACEMENT}" \ + --shadow-seat-feed-cells "${SHADOW_SEAT_FEED_CELLS}" echo "REGIONAL_PLACEMENT_ENABLED=${desired}" >> "${GITHUB_ENV}" echo "REGIONAL_PLACEMENT_VERSION=${target_version}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/cloud-deploy-relay-staging.yml b/.github/workflows/cloud-deploy-relay-staging.yml index 7ddfb9d7c733..93ef7acd2640 100644 --- a/.github/workflows/cloud-deploy-relay-staging.yml +++ b/.github/workflows/cloud-deploy-relay-staging.yml @@ -7,6 +7,46 @@ on: description: Exact checked-in production Relay sha256 digest to deploy required: true type: string + reserve-placement: + description: Preserve step-5 reserve placement, or set it; turning it off is refused while any cell is reserve + required: true + default: preserve + type: choice + options: [preserve, 'off', dry-run, 'on'] + expected-rehome-generation: + description: Exact durable regional-rehome generation; it must remain disabled + required: true + type: string + bootstrap-runtime-identity: + description: One-time move from the stamped-cell identity to the director identity + required: true + default: false + type: boolean + predecessor-image-digest: + description: Bootstrap only. Exact immutable serving predecessor digest + required: false + type: string + capacity-cell-id: + description: Register this cell's reviewed staging.tfvars cap and bound with the director + required: true + default: none + type: choice + options: [none, staging-gce-c1, staging-gce-c2, staging-gce-c3, staging-gce-c4] + min-instances: + description: Director floor; preserve keeps the serving revision's, so a reserve run's 2 survives + required: true + default: preserve + type: choice + options: [preserve, '0', '2'] + confirmation: + description: BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY for a bootstrap + required: false + type: string + shadow-seat-feed-cells: + description: Preserve the step-3 shadow seat-feed cells, or set all or a comma list of cell ids; dropping a reserve cell is refused + required: true + default: preserve + type: string permissions: contents: read @@ -34,13 +74,38 @@ jobs: IMAGE_NAME: relay EXPECTED_IMAGE_DIGEST: ${{ inputs.expected-image-digest }} CAPACITY_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }} + # The director must trust the identity every staging Relay workflow authenticates as. + DEPLOY_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }} ASIA_PROOF_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT }} REGIONAL_PLACEMENT_SECRET: orca-cloud-relay-regional-placement-enabled + DIRECTOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay-dir@onorca-cloud-staging.iam.gserviceaccount.com + PREDECESSOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay@onorca-cloud-staging.iam.gserviceaccount.com + REHOME_AUDIENCE: https://relay-staging.onorca.dev/v1/admin/host-drain + EXPECTED_REHOME_GENERATION: ${{ inputs.expected-rehome-generation }} + BOOTSTRAP_RUNTIME_IDENTITY: ${{ inputs.bootstrap-runtime-identity }} + PREDECESSOR_IMAGE_DIGEST: ${{ inputs.predecessor-image-digest }} + CAPACITY_CELL_ID: ${{ inputs.capacity-cell-id }} + MIN_INSTANCES: ${{ inputs.min-instances }} steps: - uses: actions/checkout@v4 - - name: Require the expected immutable image - run: '[[ "${EXPECTED_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]' + - name: Require the expected immutable image and identity request + shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -euo pipefail + [[ "${EXPECTED_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] + [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] + [[ "${BOOTSTRAP_RUNTIME_IDENTITY}" =~ ^(true|false)$ ]] + [[ "${MIN_INSTANCES}" =~ ^(preserve|0|2)$ ]] + if test "${BOOTSTRAP_RUNTIME_IDENTITY}" = true; then + [[ "${PREDECESSOR_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] + test "${CAPACITY_CELL_ID}" = none + test "${CONFIRMATION}" = BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY + else + test -z "${PREDECESSOR_IMAGE_DIGEST}" + fi - uses: hashicorp/setup-terraform@v3 with: @@ -68,6 +133,16 @@ jobs: test "${IMAGE}" = \ "${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}@${EXPECTED_IMAGE_DIGEST}" echo "IMAGE=${IMAGE}" >> "${GITHUB_ENV}" + if test "${CAPACITY_CELL_ID}" != none; then + # The deploy script refuses any topology change beyond this one cell's cap and bound. + DESIRED_CELLS_JSON="$(terraform -chdir=infra/terraform console \ + -var-file=environments/staging.tfvars \ + <<< 'local.relay_director_cells_json' | jq -er '.')" + jq -e --arg cell "${CAPACITY_CELL_ID}" \ + 'any(.[]; .id == $cell and .connectionHardCap != null)' \ + <<< "${DESIRED_CELLS_JSON}" >/dev/null + echo "DESIRED_CELLS_JSON=${DESIRED_CELLS_JSON}" >> "${GITHUB_ENV}" + fi - uses: google-github-actions/setup-gcloud@v2 @@ -87,12 +162,37 @@ jobs: test "${DIGEST}" = "${EXPECTED_IMAGE_DIGEST}" - name: Deploy director blue/green + env: + # An image change reads every cell's admit mode before the traffic move. + ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.google-auth.outputs.id_token }} + RESERVE_PLACEMENT: ${{ inputs.reserve-placement }} + SHADOW_SEAT_FEED_CELLS: ${{ inputs.shadow-seat-feed-cells }} run: | regional_version="$(gcloud secrets versions describe latest \ --project "${GCP_PROJECT_ID}" --secret "${REGIONAL_PLACEMENT_SECRET}" \ --format='value(name)' | awk -F/ '{print $NF}')" [[ "${regional_version}" =~ ^[1-9][0-9]*$ ]] RELEASE_ID="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_SHA:0:8}" + identity_args=() + if test "${BOOTSTRAP_RUNTIME_IDENTITY}" = true; then + identity_args=( + --bootstrap-runtime-identity true + --predecessor-runtime-service-account "${PREDECESSOR_RUNTIME_SERVICE_ACCOUNT}" + --predecessor-image-digest "${PREDECESSOR_IMAGE_DIGEST}" + ) + fi + # Omitted, the deploy script inherits the serving revision's floor. + floor_args=() + if test "${MIN_INSTANCES}" != preserve; then + floor_args=(--min-instances "${MIN_INSTANCES}") + fi + topology_args=() + if test "${CAPACITY_CELL_ID}" != none; then + topology_args=( + --capacity-cell-id "${CAPACITY_CELL_ID}" + --director-cells-json "${DESIRED_CELLS_JSON}" + ) + fi node dev/scripts/deploy-relay-blue-green.mjs \ --project "${GCP_PROJECT_ID}" \ --region "${GCP_REGION}" \ @@ -101,9 +201,20 @@ jobs: --role director \ --max-instances 2 \ --capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \ + --deploy-service-account "${DEPLOY_SERVICE_ACCOUNT}" \ --asia-proof-service-account "${ASIA_PROOF_SERVICE_ACCOUNT}" \ --regional-placement-secret-version "${regional_version}" \ - --min-instances 0 \ + "${floor_args[@]}" \ + --runtime-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ + "${identity_args[@]}" \ + --rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ + --rehome-audience "${REHOME_AUDIENCE}" \ + --rehome-control-origin https://relay-staging.onorca.dev \ + --expected-rehome-generation "${EXPECTED_REHOME_GENERATION}" \ + "${topology_args[@]}" \ + --admin-audience https://relay-staging.onorca.dev/v1/admin/drain \ + --reserve-placement "${RESERVE_PLACEMENT}" \ + --shadow-seat-feed-cells "${SHADOW_SEAT_FEED_CELLS}" \ --release-id "${RELEASE_ID}" - name: Smoke director health diff --git a/.github/workflows/cloud-operate-relay-production-cell-flags.yml b/.github/workflows/cloud-operate-relay-production-cell-flags.yml index 74576e0f6c70..a38b65978dcb 100644 --- a/.github/workflows/cloud-operate-relay-production-cell-flags.yml +++ b/.github/workflows/cloud-operate-relay-production-cell-flags.yml @@ -15,18 +15,26 @@ on: description: Exact cell id, e.g. production-gce-c26 required: true type: string - readiness-local: - description: /ready answers from the cell alone (local readiness) - required: true - default: 'false' - type: choice - options: ['false', 'true'] - ticket-check: - description: Shadow-check the director's assignment lease on host hellos + break-glass: + description: >- + Only for a dead or unreachable reserve cell, with set admitMode=default. Typed as + BREAK GLASS ; records db without the cell's read-back + required: false + default: '' + type: string + confirm-reserve: + description: Required for admitMode=reserve, typed as RESERVE + required: false + default: '' + type: string + set: + description: >- + Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a + key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck, + admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs, + reregisterInFlight required: true - default: 'off' - type: choice - options: ['off', 'shadow'] + type: string expected-generation: description: Exact current object generation from a dry run, or 0 if none exists required: true @@ -55,15 +63,17 @@ jobs: operate: if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 10 + # A flip back waits for the cell to lease every control: 8-50 minutes, scaled to its controls. + timeout-minutes: 60 environment: production env: MODE: ${{ inputs.mode }} CELL_ID: ${{ inputs.cell-id }} - READINESS_LOCAL: ${{ inputs.readiness-local }} - TICKET_CHECK: ${{ inputs.ticket-check }} + FLAG_CHANGES: ${{ inputs.set }} EXPECTED_GENERATION: ${{ inputs.expected-generation }} CONFIRMATION: ${{ inputs.confirmation }} + CONFIRM_RESERVE: ${{ inputs.confirm-reserve }} + BREAK_GLASS: ${{ inputs.break-glass }} GCP_PROJECT_ID: onorca-cloud steps: - uses: actions/checkout@v4 @@ -95,7 +105,9 @@ jobs: --mode "${MODE}" --cell-id "${CELL_ID}" \ --cell-origin "https://${CELL_ID#production-gce-}.relay.onorca.dev" \ --project-id "${GCP_PROJECT_ID}" \ - --readiness-local "${READINESS_LOCAL}" --ticket-check "${TICKET_CHECK}" \ + --set "${FLAG_CHANGES}" \ + --director-origin "https://relay.onorca.dev" --confirm-reserve "${CONFIRM_RESERVE}" \ + --break-glass "${BREAK_GLASS}" \ --expected-generation "${EXPECTED_GENERATION}" --confirmation "${CONFIRMATION}" \ | tee "${RUNNER_TEMP}/relay-cell-flags.jsonl" diff --git a/.github/workflows/cloud-operate-relay-staging-cell-flags.yml b/.github/workflows/cloud-operate-relay-staging-cell-flags.yml new file mode 100644 index 000000000000..8a239c325dc4 --- /dev/null +++ b/.github/workflows/cloud-operate-relay-staging-cell-flags.yml @@ -0,0 +1,125 @@ +name: Operate Relay Staging Cell Flags + +# The staging twin of Operate Relay Production Cell Flags: writes one staging cell's runtime +# switch object and waits for the cell to report it applied. One cell per run. dry-run writes +# nothing. +on: + workflow_dispatch: + inputs: + mode: + description: dry-run prints the change; write applies it + required: true + default: dry-run + type: choice + options: [dry-run, write] + cell-id: + description: Exact cell id, e.g. staging-gce-c2 + required: true + type: string + break-glass: + description: >- + Only for a dead or unreachable reserve cell, with set admitMode=default. Typed as + BREAK GLASS ; records db without the cell's read-back + required: false + default: '' + type: string + confirm-reserve: + description: Required for admitMode=reserve, typed as RESERVE + required: false + default: '' + type: string + set: + description: >- + Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a + key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck, + admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs, + reregisterInFlight + required: true + type: string + expected-generation: + description: Exact current object generation from a dry run, or 0 if none exists + required: true + type: string + confirmation: + description: Required for write, exactly "WRITE_RELAY_CELL_FLAGS " + required: false + type: string + +permissions: + contents: read + id-token: write + +# Not relay-staging-mutation: a switch write must run while a staging deploy or load run is +# in flight, and it touches neither Cloud SQL nor a revision. +concurrency: + group: relay-staging-cell-flags + cancel-in-progress: false + +defaults: + run: + # Explicit bash runs with pipefail, so the script's exit code survives the tee. + shell: bash + working-directory: cloud + +jobs: + operate: + if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' }} + runs-on: ubuntu-latest + # A flip back waits for the cell to lease every control: 8-50 minutes, scaled to its controls. + timeout-minutes: 60 + environment: staging + env: + MODE: ${{ inputs.mode }} + CELL_ID: ${{ inputs.cell-id }} + FLAG_CHANGES: ${{ inputs.set }} + EXPECTED_GENERATION: ${{ inputs.expected-generation }} + CONFIRMATION: ${{ inputs.confirmation }} + CONFIRM_RESERVE: ${{ inputs.confirm-reserve }} + BREAK_GLASS: ${{ inputs.break-glass }} + GCP_PROJECT_ID: onorca-cloud-staging + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - id: google-auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.STAGING_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.STAGING_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }} + token_format: id_token + id_token_audience: https://relay-staging.onorca.dev/v1/admin/drain + id_token_include_email: true + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Write or preview the cell's switch object + env: + ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.google-auth.outputs.id_token }} + run: | + [[ "${CELL_ID}" =~ ^staging-gce-c[0-9]{1,3}$ ]] + ORCA_RELAY_CONTROL_ACCESS_TOKEN="$(gcloud auth print-access-token)" + echo "::add-mask::${ORCA_RELAY_CONTROL_ACCESS_TOKEN}" + export ORCA_RELAY_CONTROL_ACCESS_TOKEN + node dev/scripts/operate-relay-cell-flags.mjs \ + --mode "${MODE}" --cell-id "${CELL_ID}" \ + --cell-origin "https://${CELL_ID#staging-gce-}.relay-staging.onorca.dev" \ + --project-id "${GCP_PROJECT_ID}" \ + --set "${FLAG_CHANGES}" \ + --director-origin "https://relay-staging.onorca.dev" --confirm-reserve "${CONFIRM_RESERVE}" \ + --break-glass "${BREAK_GLASS}" \ + --expected-generation "${EXPECTED_GENERATION}" --confirmation "${CONFIRMATION}" \ + | tee "${RUNNER_TEMP}/relay-cell-flags.jsonl" + + - name: Audit summary + if: ${{ always() }} + run: | + { + echo "### Relay staging cell flags: ${MODE} ${CELL_ID}" + echo "Triggered by: ${GITHUB_TRIGGERING_ACTOR}, run ${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}" + echo '```json' + cat "${RUNNER_TEMP}/relay-cell-flags.jsonl" 2>/dev/null || echo '(no output)' + echo '```' + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/cloud-prove-relay-asia-staging.yml b/.github/workflows/cloud-prove-relay-asia-staging.yml index 56677a98600d..4f521dd86b94 100644 --- a/.github/workflows/cloud-prove-relay-asia-staging.yml +++ b/.github/workflows/cloud-prove-relay-asia-staging.yml @@ -3,24 +3,50 @@ name: Prove Relay Asia Staging on: workflow_dispatch: inputs: - image-digest: - description: Exact immutable Relay digest deployed on staging C4 + mode: + description: prove runs the fixed C4 proof; load runs the generator with free-form arguments required: true + default: prove + type: choice + options: [prove, load] + image-digest: + description: Prove only. Exact immutable Relay digest deployed on staging C4 + required: false type: string selector-generation: - description: Exact selector generation with C4 migration-only - required: true + description: Prove only. Exact selector generation with C4 migration-only + required: false type: string promote-attempt-id: - description: Durable unique C4 promotion attempt ID - required: true + description: Prove only. Durable unique C4 promotion attempt ID + required: false type: string rollback-attempt-id: - description: Durable unique C4 rollback attempt ID - required: true + description: Prove only. Durable unique C4 rollback attempt ID + required: false + type: string + load-region: + description: Load only. asia-east2 runs on the Asia load runner; us-central1 on a hosted runner + required: false + default: asia-east2 + type: choice + options: [asia-east2, us-central1] + load-shards: + description: Load only. Generator processes, each with its own shard of the 32 load principals + required: false + default: '4' + type: choice + options: ['1', '2', '3', '4'] + load-args: + description: >- + Load only. Per-shard generator arguments, e.g. --controls 150 --splices 15 + --reconnect-max-seconds 30 --duration-seconds 900. Origins, region, principals and + sharding are set by the workflow + required: false + default: '' type: string confirmation: - description: Enter PROVE_ASIA_STAGING + description: Enter PROVE_ASIA_STAGING (prove) or LOAD_RELAY_STAGING (load) required: true type: string @@ -28,8 +54,10 @@ permissions: contents: read id-token: write +# A load run drives traffic only, so it must not queue behind (or hold up) the staging deploys +# and switch writes it is there to exercise. concurrency: - group: relay-staging-mutation + group: ${{ inputs.mode == 'load' && 'relay-staging-load' || 'relay-staging-mutation' }} cancel-in-progress: false defaults: @@ -38,7 +66,7 @@ defaults: jobs: prove: - if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') }} + if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'prove' }} runs-on: [self-hosted, linux, x64, relay-asia-east2-load] timeout-minutes: 75 environment: staging @@ -282,7 +310,7 @@ jobs: retention-days: 7 recover: - if: ${{ always() && github.ref == 'refs/heads/main' }} + if: ${{ always() && github.ref == 'refs/heads/main' && inputs.mode == 'prove' }} needs: prove runs-on: blacksmith-2vcpu-ubuntu-2204 timeout-minutes: 15 @@ -331,3 +359,116 @@ jobs: --attempt-id "${ROLLBACK_ATTEMPT_ID}" \ --image-digest "${IMAGE_DIGEST}")" test "$(jq -r '.states["staging-gce-c4"]' <<< "${result}")" = migration-only + + # The staging auth service mints relay load principals only for this workflow file, so the + # free-form staging load lives here rather than in a workflow of its own. + load: + if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'load' }} + runs-on: ${{ inputs.load-region == 'us-central1' && 'ubuntu-latest' || fromJSON('["self-hosted","linux","x64","relay-asia-east2-load"]') }} + timeout-minutes: 180 + environment: staging + env: + DIRECTOR_ORIGIN: https://relay-staging.onorca.dev + AUTH_ORIGIN: https://auth-staging.onorca.dev + LOAD_REGION: ${{ inputs.load-region }} + LOAD_SHARDS: ${{ inputs.load-shards }} + LOAD_ARGS: ${{ inputs.load-args }} + steps: + - uses: actions/checkout@v4 + + - name: Validate the staging load request + shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -euo pipefail + test "${CONFIRMATION}" = LOAD_RELAY_STAGING + [[ "${LOAD_REGION}" =~ ^(asia-east2|us-central1)$ ]] + [[ "${LOAD_SHARDS}" =~ ^[1-4]$ ]] + read -r -a args <<< "${LOAD_ARGS}" + for arg in "${args[@]}"; do + [[ "${arg}" =~ ^[A-Za-z0-9=:/._,-]+$ ]] || { echo "unsupported characters in load-args" >&2; exit 1; } + case "${arg}" in + --target-origin|--director-origin|--auth-origin|--signing-key-file|--preferred-region|\ + --relay-asia-load-principals|--shard-count|--shard-index|--phase-barrier-dir) + echo "${arg} is set by the workflow" >&2 + exit 1 + ;; + esac + done + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT }} + token_format: id_token + id_token_audience: https://relay-staging.onorca.dev/v1/admin/drain + id_token_include_email: true + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: cloud/package.json + + - name: Install exact load-harness dependencies + run: pnpm install --frozen-lockfile + + - name: Build the Relay load-harness contract + run: pnpm --filter @orca-cloud/relay-contract build + + - name: Run the sharded staging load + env: + # Probes that read director capacity need an admin token; it lasts about an hour. + ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} + shell: bash + run: | + set -euo pipefail + load_dir="${RUNNER_TEMP}/relay-staging-load" + mkdir -p "${load_dir}" + fd_limit="$(ulimit -n)" + if test "${fd_limit}" != unlimited; then + [[ "${fd_limit}" =~ ^[0-9]+$ ]] + test "${fd_limit}" -ge 4096 + fi + read -r -a args <<< "${LOAD_ARGS}" + pids=() + stop_shards() { + for pid in "${pids[@]}"; do kill "${pid}" 2>/dev/null || true; done + for pid in "${pids[@]}"; do wait "${pid}" 2>/dev/null || true; done + } + trap stop_shards EXIT + for ((shard = 0; shard < LOAD_SHARDS; shard++)); do + node dev/scripts/load-relay-controls.mjs \ + --director-origin "${DIRECTOR_ORIGIN}" \ + --auth-origin "${AUTH_ORIGIN}" \ + --preferred-region "${LOAD_REGION}" \ + --relay-asia-load-principals 32 \ + --shard-count "${LOAD_SHARDS}" \ + --shard-index "${shard}" \ + "${args[@]}" \ + > "${load_dir}/shard-${shard}.jsonl" & + pids+=("$!") + done + failed=0 + for pid in "${pids[@]}"; do + if ! wait "${pid}"; then failed=1; fi + done + trap - EXIT + for ((shard = 0; shard < LOAD_SHARDS; shard++)); do + jq -c 'select(.event == "relay_load_complete" or .event == "relay_load_progress")' \ + "${load_dir}/shard-${shard}.jsonl" | tail -n 1 || true + done + exit "${failed}" + + - name: Upload the load reports + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: relay-staging-load-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/relay-staging-load/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/cloud-prove-relay-staging-capacity.yml b/.github/workflows/cloud-prove-relay-staging-capacity.yml index 279007cbc146..15d61e91dc69 100644 --- a/.github/workflows/cloud-prove-relay-staging-capacity.yml +++ b/.github/workflows/cloud-prove-relay-staging-capacity.yml @@ -417,7 +417,6 @@ jobs: --capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \ --capacity-cell-id "${TARGET_CELL_ID}" \ --director-cells-json "${DESIRED_CELLS_JSON}" \ - --min-instances 0 \ --prune-revisions true \ --release-id "${RELEASE_ID}")" echo "${DEPLOY_RESULT}" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 548c34d69a29..b0dc99ddbdee 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,10 +27,6 @@ on: required: false type: boolean default: true - ssh_source_changed: - description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane' - required: false - type: string workflow_dispatch: inputs: ref: @@ -298,8 +294,6 @@ jobs: - name: Install native build and headless UI tools # Why ripgrep: Quick Open's bounded host-side search requires rg instead of an # unbounded inventory fallback; the paired fixture exercises that real boundary. - # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this - # lane now receives those specs from pr.yml's SSH source mapping. env: *e2e_tool_packages run: *install_e2e_tools @@ -323,42 +317,7 @@ jobs: else # Dispatching an older ref retains its existing dedicated-owner exclusions. jq -r '.[] | select( - . != "tests/e2e/local-ssh-browser-routing.spec.ts" and - . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and - . != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and - . != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and - . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and - . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and - . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and - . != "tests/e2e/ssh-emptied-worktree-reactivation.spec.ts" and - . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and - . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and - . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and - . != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and - . != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and - . != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and - . != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and - . != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and - . != "tests/e2e/ssh-external-image-preview.spec.ts" and - . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and - . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and - . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and - . != "tests/e2e/ssh-reattach-home-partition.spec.ts" and - . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and - . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and - . != "tests/e2e/ssh-skill-installation.spec.ts" and - . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and - . != "tests/e2e/ssh-startup-local-shadow.spec.ts" and - . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and - . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and - . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and - . != "tests/e2e/ssh-terminal-parking.spec.ts" and - . != "tests/e2e/terminal-retention-budget.spec.ts" and - . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and - . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and - . != "tests/e2e/workspace-layout-oracle-ssh.spec.ts" and . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and - . != "tests/e2e/ssh-localhost.spec.ts" and . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and . != "tests/e2e/orcad-serve-mode-switch.spec.ts" and . != "tests/e2e/workspace-layout-oracle-headless.spec.ts" and @@ -377,7 +336,10 @@ jobs: . != "tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts" and . != "tests/e2e/ssh-orcad-explorer-selected-host.spec.ts" and . != "tests/e2e/ssh-orcad-browser-drop-owner.spec.ts" and - . != "tests/e2e/ssh-orcad-terminal-root-owner.spec.ts" + . != "tests/e2e/ssh-orcad-terminal-root-owner.spec.ts" and + . != "tests/e2e/ssh-orcad-terminal-journeys.spec.ts" and + . != "tests/e2e/ssh-orcad-workspace-tools.spec.ts" and + . != "tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -386,13 +348,6 @@ jobs: exit 0 fi E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay") - # Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so - # naming only one trigger silently skipped every other Docker-SSH spec in this lane. - # The first clause stays because that spec needs Docker without referencing the variable. - if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \ - || grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then - E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1) - fi E2E_PROJECT_ARGS=() if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then E2E_PROJECT_ARGS+=(--project=electron-headful) @@ -409,124 +364,6 @@ jobs: retention-days: 7 if-no-files-found: ignore - ssh-docker-watcher-isolation: - name: ssh docker watcher isolation (${{ matrix.shard }}/4) - needs: [build, prepare-native-cache] - # Each excluded changed spec must trigger its dedicated owner. - if: >- - inputs.test_files == '' || - inputs.ssh_source_changed == 'true' || - contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') || - contains(inputs.test_files, 'tests/e2e/pty-input-write-queue-ssh.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-ai-vault-session-history.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-resource-accumulation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-external-image-preview.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-port-forward-lifecycle.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-restart-tab-accumulation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-skill-installation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts') || - contains(inputs.test_files, 'tests/e2e/terminal-inline-images-ssh.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-watcher-isolation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-terminal-parking.spec.ts') || - contains(inputs.test_files, 'tests/e2e/terminal-retention-budget.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || - contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') || - contains(inputs.test_files, 'tests/e2e/workspace-layout-oracle-ssh.spec.ts') - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - - name: Install native build and headless UI tools - env: *e2e_tool_packages - run: *install_e2e_tools - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - - name: Download E2E build output - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - - # Why: this is the release-path proof that the deployed Linux relay keeps - # its PTY and explorer live across a real watcher SIGSEGV. - - name: Run Docker SSH watcher isolation E2E - if: matrix.shard == 1 - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation - - # Why: Playwright empties test-results/ when it starts, so each step here used to - # destroy the previous step's traces. Only the last lane's failure was ever - # diagnosable from the artifact; set each lane aside before the next one runs. - - name: Keep watcher-isolation traces - if: always() && matrix.shard == 1 - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/watcher-isolation" - fi - - # Keep every SSH journey after a test failure; stop work on superseded commits. - - name: Run Docker SSH terminal parking + startup readiness E2E - if: '!cancelled() && matrix.shard == 1' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - - - name: Keep terminal-parking traces - if: always() && matrix.shard == 1 - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/terminal-parking" - fi - - # Separate VMs isolate destructive SSH fixtures while keeping one worker per shard. - - name: Run remaining Docker SSH E2E - if: '!cancelled()' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4 - - - name: Keep remaining-ssh-docker traces - if: always() - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/remaining-ssh-docker" - fi - - - name: Upload watcher isolation traces - if: failure() || cancelled() - uses: actions/upload-artifact@v7 - with: - name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }} - path: e2e-traces/ - retention-days: 7 - if-no-files-found: ignore - ssh-browser-network-route: name: ssh browser network route if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts') @@ -656,6 +493,54 @@ jobs: retention-days: 7 if-no-files-found: ignore + # SSH user journeys (terminal reconnect/restore/faults, Quick Open, ports, links, agent status) on a + # Docker host served by managed orcad. Its own runner so the convert lane keeps its time budget. + orcad-journeys-docker: + name: ssh journeys on managed orcad (Docker) + needs: [build, prepare-native-cache] + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-workspace-tools.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts') + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + - name: Install headless tools + run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + - uses: actions/download-artifact@v8 + with: + name: e2e-build-out + path: out/ + - name: Build the linux-x64-glibc orcad template + run: | + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots linux-x64-glibc + pnpm build:orcad + node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc + mv out/orcad-template "$RUNNER_TEMP/orcad-convert-template" + - name: Run SSH journeys on a managed Docker host + env: + SKIP_BUILD: '1' + ORCA_E2E_SSH_DOCKER: '1' + ORCA_E2E_ORCAD_CONVERT_HOST: docker + ORCA_E2E_FORWARD_APP_LOGS: '1' + run: | + export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-terminal-journeys.spec.ts tests/e2e/ssh-orcad-workspace-tools.spec.ts tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts --project=electron-headless --workers=1 + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: orcad-journeys-docker-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + # D7 on Windows: both hosts share \daemon and so one daemon pipe. Built here rather than # from the Linux e2e build because the slot, template and addons are Windows-native. Also runs the # missing-AppData startup check, which needs the same Windows e2e build. @@ -679,6 +564,9 @@ jobs: with: resolve-windows-cache: 'true' restore-windows-cache: 'true' + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: x64 - name: Build the e2e app, CLI, orcad slot and template shell: bash run: | @@ -710,67 +598,3 @@ jobs: path: test-results/ retention-days: 7 if-no-files-found: ignore - - ssh-localhost: - name: localhost SSH terminal and hooks - needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-localhost.spec.ts') - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - name: Install SSH server and headless tools - run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client openssh-server python3 ripgrep xvfb zsh openbox x11-utils - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - - name: Start isolated localhost SSH server - shell: bash - run: | - # Bare shells install Pi extensions only for an existing agent home. - mkdir -p "$HOME/.pi/agent" - fixture="$RUNNER_TEMP/orca-localhost-sshd" - mkdir -p "$fixture" - ssh-keygen -q -t ed25519 -N '' -f "$fixture/host_key" - ssh-keygen -q -t ed25519 -N '' -f "$fixture/client_key" - cat > "$fixture/sshd_config" <> "$GITHUB_ENV" - - name: Run localhost SSH terminal and hook journey - env: - SKIP_BUILD: '1' - ORCA_E2E_SSH_LOCALHOST: '1' - ORCA_FEATURE_REMOTE_AGENT_HOOKS: '1' - ORCA_E2E_FORWARD_APP_LOGS: '1' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1 - - uses: actions/upload-artifact@v7 - if: failure() || cancelled() - with: - name: localhost-ssh-traces - path: test-results/ - retention-days: 7 - if-no-files-found: ignore diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 90c3fc68473d..66946a8e98f0 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -100,16 +100,18 @@ jobs: if [ "$EVENT_NAME" = push ]; then # Compare the entire push, including multi-commit pushes and removed files. if git fetch --no-tags --depth=1 origin "$PUSH_BASE" && - git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification + git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes" && + git diff --name-only --no-renames --diff-filter=M -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-modified"; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification --modified-paths "$RUNNER_TEMP/node-server-modified" else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi exit 0 fi # Compare the tested merge with its base, retaining both sides of renames. - if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph + if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes" && + git diff --name-only --no-renames --diff-filter=M -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-modified"; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --modified-paths "$RUNNER_TEMP/node-server-modified" else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi @@ -128,9 +130,9 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | if [ "$EVENT_NAME" = push ]; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification --modified-paths "$RUNNER_TEMP/node-server-modified" else - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --modified-paths "$RUNNER_TEMP/node-server-modified" fi persistence: @@ -192,6 +194,11 @@ jobs: (github.event_name == 'pull_request' || (github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }} restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} + # The template lane cross-compiles arm64 too, as it does for the process-table addon. + - uses: ./.github/actions/prepare-windows-cli-launcher + if: runner.os == 'Windows' + with: + arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }} - run: pnpm build:orcad - wait: bun-orcad - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} @@ -216,6 +223,15 @@ jobs: if-no-files-found: error retention-days: 7 overwrite: true + - name: Keep the Windows CLI launchers for the desktop template + if: inputs.build_template && matrix.os == 'windows-2022' + uses: actions/upload-artifact@v7 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher/ + if-no-files-found: error + retention-days: 7 + overwrite: true - uses: actions/setup-node@v6 if: runner.arch == 'X64' with: @@ -458,6 +474,11 @@ jobs: with: name: orcad-windows-process-tree path: .build/windows-process-tree + - name: Collect the Windows CLI launchers + uses: actions/download-artifact@v8 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher - name: Merge the lanes and gate the full slot matrix shell: bash run: | diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml index 28ece6796e77..e3f952fd7af3 100644 --- a/.github/workflows/performance-contracts.yml +++ b/.github/workflows/performance-contracts.yml @@ -22,7 +22,6 @@ on: - 'src/main/codex-usage/scanner-incremental-append.test.ts' - 'src/main/usage/jsonl-file-reader.test.ts' - 'src/main/usage/jsonl-file-snapshot.test.ts' - - 'src/relay/fs-path-metadata-symlink-concurrency.test.ts' - 'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts' - 'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts' - 'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dd1e0ab2e5df..d1bfe867fa60 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -53,7 +53,6 @@ jobs: test_files: ${{ steps.e2e_filter.outputs.test_files }} e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }} e2e_needs_build: ${{ steps.e2e_filter.outputs.e2e_needs_build }} - ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }} native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }} wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }} steps: @@ -131,12 +130,7 @@ jobs: # authorities, exclusions, and sentinels without evaluating workflow shell. TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" - # Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a - # spec name surviving in a route's list. Same routes, so the two cannot drift. - SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)" - echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" - echo "SSH source changed: $SSH_SOURCE_CHANGED" - printf '%s\n' "$TEST_FILES_JSON" | E2E_SSH_SOURCE_CHANGED="$SSH_SOURCE_CHANGED" node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT" + printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT" # Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must # trigger on IME source rather than on a spec name in some route's list. NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)" @@ -197,11 +191,11 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml - - name: Set up Bun for localization checks - if: needs.code_paths.outputs.static_analysis == 'true' + # Bun 1.4.3 introduces the compiler used by this job. + - name: Set up Bun for preflight checks uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version-file: config/.bun-version + bun-version: 1.4.3 - name: Check process-host imports if: '!cancelled()' @@ -212,7 +206,7 @@ jobs: PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - pnpm run check:process-host-imports + bun --bun run check:process-host-imports # Keep each check in its own log while sharing this runner. - name: Lint @@ -228,7 +222,9 @@ jobs: - name: Reject low-evidence patterns if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run audit:anti-slop + run: | + node config/scripts/sync-anti-slop-plugin.mjs + bun --smol node_modules/oxlint/bin/oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings - wait: root-lint @@ -273,6 +269,18 @@ jobs: - wait: changed-code-quality + # The compiler overlaps the remaining checks after the type-aware passes release memory. + - name: Typecheck + if: '!cancelled()' + id: typecheck + background: true + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run typecheck:ci + - name: Check Zustand selector fan-out budget if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:zustand-selector-fanout @@ -425,19 +433,6 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm verify:macos-entitlements - # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets - # the next run skip unchanged files. Share one cache entry across commits while the - # PR base stays stable; actions/cache keeps the first successful graph and the - # compiler still invalidates stale files from its content hashes. - - name: Cache TypeScript incremental state - if: needs.code_paths.outputs.typecheck == 'true' - uses: actions/cache@v5 - with: - path: config/*.tsbuildinfo - key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.event.pull_request.base.sha }} - restore-keys: | - tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- - # Planning shares setup and stays off the compiler's critical path. - name: Plan unit selection if: '!cancelled()' @@ -453,8 +448,7 @@ jobs: if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi node config/scripts/ci-unit-plan.mjs - - run: pnpm run typecheck - if: needs.code_paths.outputs.typecheck == 'true' + - wait: typecheck - wait: process-host-imports @@ -509,8 +503,7 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/shared/git-binary-compatibility.test.ts \ src/main/git/worktree-safety-real-git.test.ts \ - src/main/git/worktree-rebase-update-refs-real-git.test.ts \ - src/relay/git-review-draft-binary-compatibility.test.ts + src/main/git/worktree-rebase-update-refs-real-git.test.ts ) & pids+=("$!") @@ -522,8 +515,7 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/shared/git-binary-compatibility.test.ts \ src/main/git/worktree-safety-real-git.test.ts \ - src/main/git/worktree-rebase-update-refs-real-git.test.ts \ - src/relay/git-review-draft-binary-compatibility.test.ts + src/main/git/worktree-rebase-update-refs-real-git.test.ts ) & pids+=("$!") done @@ -798,7 +790,6 @@ jobs: src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ @@ -978,17 +969,11 @@ jobs: - uses: ./.github/actions/install-node-dependencies - - name: Build relay companions - run: pnpm run build:relay - - name: Setup Node 18 runtime uses: actions/setup-node@v6 with: node-version: '18' - - name: Smoke managed-hook companions - run: node config/scripts/smoke-managed-hook-runtime-node18.mjs - # The process-host package declares engines >=18; load its staged public output on Node 18. - name: Smoke process-host package run: node config/scripts/smoke-process-host-node18.mjs @@ -1216,12 +1201,14 @@ jobs: - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts + src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts config/scripts/rebuild-native-deps.test.mjs config/scripts/rebuild-native-deps-windows-process-tree.test.mjs config/scripts/rebuild-native-deps-node-pty.test.mjs config/scripts/ensure-native-runtime-job-ownership.test.mjs config/scripts/verify-packaged-node-pty-job-ownership.test.mjs config/scripts/windows-pe-machine.test.mjs + config/scripts/build-windows-cli-launcher.test.mjs config/scripts/script-module-dependencies.test.mjs src/main/windows-registry-addon.test.ts config/scripts/windows-process-tree-gyp-path.test.mjs @@ -1277,10 +1264,7 @@ jobs: src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts src/main/ipc/preflight-provider-command-selection.test.ts src/main/ipc/preflight-runnable-local-cli.test.ts - src/relay/windows-port-scan.win32.test.ts - src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts src/main/ssh/remote-node-runtime-store-windows.test.ts - config/scripts/node-pty-windows-pty-teardown-patch.test.mjs # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package @@ -1353,7 +1337,6 @@ jobs: # workflow is queued. The head SHA is immutable and works for every PR. ref: ${{ github.event.pull_request.head.sha }} test_files: ${{ needs.code_paths.outputs.test_files }} - ssh_source_changed: ${{ needs.code_paths.outputs.ssh_source_changed }} run_changed_e2e: ${{ needs.code_paths.outputs.e2e_run_changed != 'false' }} needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }} diff --git a/.github/workflows/preflight-benchmark.yml b/.github/workflows/preflight-benchmark.yml new file mode 100644 index 000000000000..f141b8e60256 --- /dev/null +++ b/.github/workflows/preflight-benchmark.yml @@ -0,0 +1,60 @@ +name: Preflight comparison +on: + pull_request: + paths: + - .github/workflows/preflight-benchmark.yml + - config/scripts/benchmark-preflight-*.mjs + - config/scripts/preflight-benchmark-command.mjs + workflow_dispatch: +permissions: + contents: read +concurrency: + group: preflight-comparison-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + ORCA_BACKGROUND_LAUNCH: '1' +jobs: + compilers: + runs-on: ubuntu-24.04-arm + timeout-minutes: 25 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: none + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.3 + - name: Compare native and Bun typechecking + run: node config/scripts/benchmark-preflight-compilers.mjs + - uses: actions/upload-artifact@v7 + if: always() + with: + name: preflight-compilers + path: .build/preflight-benchmark/ + retention-days: 30 + static-analysis: + runs-on: ubuntu-24.04-arm + timeout-minutes: 25 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: none + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.3 + - name: Compare anti-slop patch and script runtimes + run: node config/scripts/benchmark-preflight-static-analysis.mjs + - uses: actions/upload-artifact@v7 + if: always() + with: + name: preflight-static-analysis + path: .build/preflight-benchmark/ + retention-days: 30 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index d6282a804a02..174b2e3c0117 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1520,12 +1520,6 @@ jobs: node config/scripts/runtime-file-watcher-fault-harness.mjs ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs - - name: Gate SSH relay watcher process isolation - run: | - # Why: the remote native watcher shares a daemon with live PTYs. - # Kill only its child and require both PTY and watch recovery before packaging. - node config/scripts/relay-watcher-fault-harness.mjs - # Why: main ships minified with sourcemap:'hidden' and packaging drops # out/**/*.map from app.asar, so a crash trace from a released build is # otherwise undecodable. The main bundle is platform-independent, so one diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 22558411c8c1..ee8a183f9e4f 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -195,12 +195,6 @@ jobs: node config/scripts/runtime-file-watcher-fault-harness.mjs ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs - - name: Gate SSH relay watcher process isolation - run: | - # Why: the remote native watcher shares a daemon with live PTYs. - # Kill only its child and require both PTY and watch recovery before packaging. - node config/scripts/relay-watcher-fault-harness.mjs - - name: Abort if the parent release-cut run was cancelled # Why: this workflow is dispatched separately, so cancelling release-cut # does not stop mac `--publish always`. A cancelled parent left v1.4.206 diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index fc319e9f9762..368a97ee2879 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -16,7 +16,6 @@ on: - 'src/main/ssh/ssh-remote-*' - 'src/main/ssh/ssh-hostile-host-*' - 'src/main/ssh/sftp-*' - - 'src/relay/**' - 'src/wsl-guest/**' - 'src/shared/node-runtime-pin.ts' - 'src/shared/orcad-artifacts.ts' @@ -26,7 +25,7 @@ on: - 'config/scripts/verify-packaged-orcad-template.cjs' - 'config/patches/node-pty*' - '!src/**/*.test.ts' - - 'src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts' + - 'src/main/ssh/ssh-hostile-hosts.docker.test.ts' - '.github/workflows/ssh-hostile-hosts.yml' - '.github/actions/restore-pnpm-verification/**' workflow_dispatch: @@ -201,7 +200,7 @@ jobs: env: ORCA_RUN_SSH_HOSTILE_HOSTS: '1' ORCA_SSH_HOSTILE_HOST_CELLS: ${{ github.event.inputs.cells || '' }} - run: pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts + run: pnpm test:node src/main/ssh/ssh-hostile-hosts.docker.test.ts # Design D6 on macOS: the runner is the SSH host. A user-level sshd on a loopback port logs in # as the runner user with PATH cut to the toolchain shims and /usr/bin:/bin, so Homebrew's node @@ -240,4 +239,4 @@ jobs: env: ORCA_RUN_SSH_HOSTILE_HOSTS: '1' ORCA_SSH_HOSTILE_HOST_CELLS: ${{ matrix.cell }} - run: pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts + run: pnpm test:node src/main/ssh/ssh-hostile-hosts.docker.test.ts diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 752f3ff9a2a0..e70fd5a8d6e7 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -17,7 +17,6 @@ on: - 'src/main/ssh/ssh-hostile-host-*' - 'src/main/ssh/ssh-windows-host-*' - 'src/main/ssh/ssh-session-command-audit.ts' - - 'src/relay/**' - 'src/wsl-guest/**' - 'src/packages/process-host/**' - 'pnpm-workspace.yaml' @@ -26,18 +25,21 @@ on: - 'config/scripts/build-orcad-*.mjs' - 'config/scripts/orcad-prebuild-*.mjs' - 'config/scripts/orcad-windows-process-tree.mjs' + - 'config/scripts/orcad-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'native/windows-cli-launcher/**' + - 'src/main/orcad/orcad-cli-launcher.ts' + - '.github/actions/prepare-windows-cli-launcher/**' - 'config/scripts/build-relay.mjs' - 'config/patches/node-pty*' - 'config/patches/@vscode__windows-process-tree*' - 'config/scripts/build-windows-process-tree-relay-addon.mjs' - - 'config/scripts/relay-windows-process-tree-staging.mjs' - 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs' - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' - 'src/shared/relay-windows-breakaway-launch.ts' - 'src/shared/windows-breakaway-launch*.ts' - 'src/main/ipc/ssh-host-server-*.ts' - '!src/**/*.test.ts' - - 'src/main/ssh/ssh-relay-windows-host-lane.test.ts' - 'src/main/ssh/orcad-windows-host-lane.test.ts' - 'tests/e2e/ssh-orcad-auto-convert.spec.ts' - 'tests/e2e/helpers/orcad-convert-host.ts' @@ -150,6 +152,9 @@ jobs: with: resolve-windows-cache: ${{ github.event_name == 'pull_request' }} restore-windows-cache: ${{ github.event_name == 'pull_request' }} + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: ${{ matrix.arch }} - name: Build the win32 template and the relay shell: bash env: @@ -170,11 +175,11 @@ jobs: $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' New-Item -ItemType Directory -Force -Path $receipts | Out-Null $cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_}) - if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')} + if(-not $cells.Count){$cells=@('orcad-cmd','orcad-powershell')} # One Windows host runs the app-level conversion; it is last because it switches native modules. if(-not $env:CELLS -and '${{ matrix.arch }}' -eq 'x64' -and '${{ matrix.server }}' -eq 'inbox'){$cells+='orcad-convert'} # App cells reach the managed server through an SSH local forward, which provisioning grants to the last accounts only. - $appCellIds=@('orcad-convert','orcad-cli-managed','orcad-cli-convert','orcad-cli-relay-kept') + $appCellIds=@('orcad-convert','orcad-cli-managed','orcad-cli-convert') $cells=@($cells | Where-Object {$appCellIds -notcontains $_})+@($cells | Where-Object {$appCellIds -contains $_}) $forwarding=@($cells | Where-Object {$appCellIds -contains $_}).Count $archive='' diff --git a/.github/workflows/win-orcad-serve-switch-e2e.yml b/.github/workflows/win-orcad-serve-switch-e2e.yml index 776b0db72ed8..6e105876008a 100644 --- a/.github/workflows/win-orcad-serve-switch-e2e.yml +++ b/.github/workflows/win-orcad-serve-switch-e2e.yml @@ -78,6 +78,9 @@ jobs: with: resolve-windows-cache: 'true' restore-windows-cache: 'true' + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: x64 - name: Build the win32-x64 orcad template shell: bash run: | diff --git a/.github/workflows/zcode-account-contract.yml b/.github/workflows/zcode-account-contract.yml new file mode 100644 index 000000000000..082648142f2c --- /dev/null +++ b/.github/workflows/zcode-account-contract.yml @@ -0,0 +1,36 @@ +name: ZCode read-only account contract +on: + pull_request: + paths: + - 'src/main/zcode/**' + - 'src/shared/node-bounded-file-reader*' + - 'src/main/rate-limits/**' + - '.github/workflows/zcode-account-contract.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: zcode-account-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + credential-files: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: config/.bun-version + - name: Verify read-only credentials and account changes + run: pnpm test src/main/zcode/zcode-v2-plan-credentials.test.ts src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts src/main/rate-limits/service-zcode-usage.test.ts src/shared/node-bounded-file-reader-sync.test.ts diff --git a/.github/workflows/zcode-credential-contract.yml b/.github/workflows/zcode-credential-contract.yml new file mode 100644 index 000000000000..3e991b22a4e2 --- /dev/null +++ b/.github/workflows/zcode-credential-contract.yml @@ -0,0 +1,37 @@ +name: Coding Plan credential contract +on: + pull_request: + paths: + - 'src/main/zcode/**' + - 'src/shared/secure-file*' + - 'src/shared/secure-path-windows-acl*' + - 'src/shared/windows-security-descriptor*' + - '.github/workflows/zcode-credential-contract.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: coding-plan-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + credential-files: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: config/.bun-version + - name: Verify rollback and native Windows permissions + run: pnpm test src/main/zcode/zcode-plan-api-key-store.test.ts src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts src/shared/secure-file.test.ts diff --git a/.github/workflows/zcode-transcript-contract.yml b/.github/workflows/zcode-transcript-contract.yml new file mode 100644 index 000000000000..bb2b86cec484 --- /dev/null +++ b/.github/workflows/zcode-transcript-contract.yml @@ -0,0 +1,40 @@ +name: ZCode transcript contract +on: + pull_request: + paths: + - 'src/main/ai-vault/session-scanner-zcode*' + - 'src/main/ai-vault/session-scanner-opencode*' + - 'src/main/ai-vault/opencode-wsl-runtime*' + - 'src/main/native-chat/transcript-opencode*' + - 'src/main/native-chat/transcript-zcode*' + - 'src/shared/native-chat-types.ts' + - 'src/shared/native-chat-transcript-projection*' + - '.github/workflows/zcode-transcript-contract.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: zcode-transcript-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + transcript-readers: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: config/.bun-version + - name: Verify SQLite ordering, legacy readers, and host routing + run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/native-chat/transcript-opencode2-sqlite.test.ts src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts src/main/native-chat/transcript-opencode-read-bounds.test.ts src/main/native-chat/transcript-opencode-subscribe.test.ts src/main/ai-vault/session-scanner-zcode.test.ts src/main/ai-vault/session-scanner-zcode-sources.test.ts src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts src/shared/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts diff --git a/.gitignore b/.gitignore index e31f15b56bad..03e9d512a742 100644 --- a/.gitignore +++ b/.gitignore @@ -210,5 +210,8 @@ tests/e2e/.cross-version-checkouts/ # Generated by the CI unit-test sequencer (and by reproducing a shard locally). ci-shards/ -# Local-only design/planning docs (not checked in). -# locations below (assets, readme, STYLEGUIDE, and mobile terminal shortcut bar). + +# Paired CI timing reports and temporary baseline plugin. +.build/preflight-benchmark/ + +.local/static-analysis-research/ diff --git a/cloud/apps/relay/src/admin-token-verifier.test.ts b/cloud/apps/relay/src/admin-token-verifier.test.ts index 342144c4bff3..628e31f7b1a2 100644 --- a/cloud/apps/relay/src/admin-token-verifier.test.ts +++ b/cloud/apps/relay/src/admin-token-verifier.test.ts @@ -17,6 +17,7 @@ const mutationRoutes = [ '/v1/admin/admission-selector/apply', '/v1/admin/admission-selector/add-migration-cells', '/v1/admin/cell-state', + '/v1/admin/cell-admit-mode', '/v1/admin/cell-fence-adopt-legacy', '/v1/admin/cell-fence-commit-legacy-adoption', '/v1/admin/cell-fence-attest', diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index db8586719d31..42bcb311b312 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -29,6 +29,7 @@ import { } from './admin-token-verifier.js' import { RelayAssignmentRowBusyError, + RelayCellInReserveModeError, RelayHomeCellUnavailableError, type CellFenceAttemptEvidence, type RelayAssignment, @@ -135,6 +136,7 @@ export function createRelayApp( cellDemote?: (request: DemoteRequest) => string cellReserverPoll?: () => void cellAdmitModeEffective?: () => 'db' | 'reserve' + cellAdmitModeRaw?: () => 'db' | 'reserve' cellReserveCounts?: () => { bookings: number intake: { perSec: number; burst: number; tokens: number } @@ -298,7 +300,9 @@ export function createRelayApp( error: unknown, status: 404 | 409 ): Response => { - if (!isRelayDatabaseTransientError(error)) { + // The reserve-mode set could not be read: retry, as for a database blip. + const unknownReserveSet = error instanceof RelayCellInReserveModeError && error.cellId === null + if (!isRelayDatabaseTransientError(error) && !unknownReserveSet) { return context.json({ error: operationError(error) }, status) } context.header('Retry-After', String(config.publicAssignmentRetryAfterSeconds)) @@ -350,6 +354,7 @@ export function createRelayApp( cellFlags: operations.cellFlags, onReserverPoll: operations.cellReserverPoll, admitModeEffective: operations.cellAdmitModeEffective, + admitModeRaw: operations.cellAdmitModeRaw, reserveCounts: operations.cellReserveCounts, placementCeiling: operations.cellPlacementCeiling }) @@ -1222,6 +1227,44 @@ export function createRelayApp( return rejectAdminOperation(context, error, 409) } }) + // Step 5: the flag workflow's record of a cell's admitMode, which every sweep reads. Without + // `admitMode` it only reads; reserve is refused while a drain, migration or rehome is open. + app.post('/v1/admin/cell-admit-mode', async (context) => { + const bearer = readBearer(context.req.header('authorization')) + if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) + if (!bearer || !(await verifyAdminToken(bearer))) { + return context.json({ error: 'invalid_token' }, 401) + } + if (requestTooLarge(context.req.header('content-length'))) { + return context.json({ error: 'request_too_large' }, 413) + } + const body = AdminCellAdmitModeSchema.safeParse(await context.req.json().catch(() => null)) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + try { + if (body.data.admitMode) { + const before = await operations.assignments.cellAdmitMode(body.data.cellId) + await operations.assignments.setCellAdmitMode(body.data.cellId, body.data.admitMode) + // The sweeps' switch: every write is on the record, the break-glass flip included. + console.warn( + JSON.stringify({ + event: 'orca_relay_cell_admit_mode_recorded', + cellId: body.data.cellId, + from: before.admitMode, + to: body.data.admitMode + }) + ) + } + return context.json({ + v: 1, + cellId: body.data.cellId, + ...(await operations.assignments.cellAdmitMode(body.data.cellId)), + // The break-glass flip refuses a cell that is still heartbeating. + heartbeatFresh: await operations.assignments.cellHeartbeatFresh(body.data.cellId) + }) + } catch (error) { + return rejectAdminOperation(context, error, 409) + } + }) app.post('/v1/admin/cell-fence-adopt-legacy', async (context) => { const bearer = readBearer(context.req.header('authorization')) if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) @@ -2044,6 +2087,10 @@ const AdminAdmissionSelectorAddMigrationCellsSchema = z new Set(cells.map(({ cellUrl }) => cellUrl)).size === cells.length ) +const AdminCellAdmitModeSchema = z + .object({ v: z.literal(1), cellId: CellIdSchema, admitMode: z.enum(['db', 'reserve']).optional() }) + .strict() + const AdminCellStateSchema = z.union([ z.object({ v: z.literal(1), cellId: CellIdSchema, enabled: z.boolean() }).strict(), z.object({ v: z.literal(1), cellId: CellIdSchema, state: CellAdmissionStateSchema }).strict() diff --git a/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts new file mode 100644 index 000000000000..a10b49940132 --- /dev/null +++ b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts @@ -0,0 +1,387 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' +import ts from 'typescript' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore, RelayCellInReserveModeError } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' + +// Step 5 census (design §6.3): a reserve-mode cell keeps its hosts in memory, so Postgres's +// per-host state for it is stale. Every store method is classified below; a new one fails +// this test until someone decides how it treats a reserve-mode cell. +const RESERVE_MODE_CENSUS: Record = { + // One host's connect, resolve or lease: a reserve-mode cell answers these from memory, and + // its ledger row is written after the fact. + acquireActivity: 'connect-path', + activateControl: 'connect-path', + // A flip back's re-registration: the cell is leaving reserve mode, so its rows are rebuilt. + activateControlDeferringCell: 'connect-path', + assign: 'connect-path', + changeActivity: 'connect-path', + exchangeRegionCorrection: 'connect-path', + hostWhereabouts: 'connect-path', + markMigrationTargetRegistered: 'connect-path', + releaseActivity: 'connect-path', + renewControlActivities: 'connect-path', + renewControlActivity: 'connect-path', + resolve: 'connect-path', + verifyCellAssignment: 'connect-path', + // Sweeps and per-cell host readers: skip reserve-mode cells, or refuse them. + cellDeploymentStatus: 'refuses-reserve-cells', + cellEvacuationCapacity: 'refuses-reserve-cells', + cellEvacuationStatus: 'refuses-reserve-cells', + evacuateDeadCells: 'skips-reserve-cells', + prepareCellDrainAttempt: 'refuses-reserve-cells', + rebalanceDormant: 'refuses-reserve-cells', + releaseExpiredActivity: 'skips-reserve-cells', + releaseExpiredActivityLeases: 'skips-reserve-cells', + selectIdleRegionalRehomeCandidates: 'skips-reserve-cells', + startActiveCellEvacuations: 'refuses-reserve-cells', + startEvacuation: 'refuses-reserve-cells', + // Steps of a drain, migration or rehome that only exist once a refused start above began one. + abortExpiredEvacuations: 'follows-a-refused-start', + abortExpiredRegionalRehomes: 'follows-a-refused-start', + abortUnarrivedRegionalRehomes: 'follows-a-refused-start', + beginCellDrainSend: 'follows-a-refused-start', + commitIdleRegionalRehome: 'follows-a-refused-start', + // A reserve source keeps no activity rows, so its "no source units" check proves nothing. + completeEvacuation: 'refuses-reserve-cells', + completeEvacuationFromDeadSource: 'refuses-reserve-cells', + completeReadyEvacuations: 'follows-a-refused-start', + completeReadyRegionalRehomes: 'follows-a-refused-start', + prepareCellDrainRecovery: 'follows-a-refused-start', + proveCellDrainNotDelivered: 'follows-a-refused-start', + reapRegionalRehomeAttempts: 'follows-a-refused-start', + reconcileIdleRegionalRehome: 'follows-a-refused-start', + recordCellDrainApplicationReceipt: 'follows-a-refused-start', + refreshRegionalRehomeLeases: 'follows-a-refused-start', + supersedeRegisteredCellEvacuations: 'follows-a-refused-start', + supersedeRegisteredEvacuation: 'follows-a-refused-start', + // Cell-level state, fences and controls: no per-host rows. + abortCellFenceAttempt: 'cell-level', + addMigrationCells: 'cell-level', + adoptLegacyCellFence: 'cell-level', + applyCellAdmissionSelector: 'cell-level', + applyRegionalRehomeControl: 'cell-level', + cellAdmitMode: 'cell-level', + cellHeartbeatFresh: 'cell-level', + recordCellAdmitEffective: 'cell-level', + commitCellReservationDelta: 'cell-level', + attestCellFence: 'cell-level', + attestCellFenceAttempt: 'cell-level', + bindCellFencePlanGeneration: 'cell-level', + cellFenceAttempt: 'cell-level', + commitLegacyCellFenceAdoption: 'cell-level', + configureCell: 'cell-level', + disableRegionalRehomeControl: 'cell-level', + inspectCellAdmissionSelector: 'cell-level', + inspectRegionalRehomeControl: 'cell-level', + prepareCellFenceAttempt: 'cell-level', + previewRegionCorrection: 'cell-level', + previewRegionalRehomeEligibility: 'cell-level', + pruneReleasedControlReservations: 'cell-level', + reconcileCells: 'cell-level', + reconcileCellsAtStartup: 'cell-level', + reserveModeCells: 'cell-level', + recordCellFenceOperation: 'cell-level', + recordCellHeartbeat: 'cell-level', + recordCellRegionalRehomeStatus: 'cell-level', + regionCatalog: 'cell-level', + regionCorrectionOutcomes: 'cell-level', + regionalRehomeFleetSafety: 'cell-level', + releaseExpiredRegionPreferences: 'cell-level', + seatFeedCells: 'cell-level', + setCellAdmissionState: 'cell-level', + setCellAdmitMode: 'cell-level', + setCellEnabled: 'cell-level', + startCellFenceApply: 'cell-level' +} + +const CELLS: RelayCellConfig[] = [ + { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, + { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } +] + +describe('reserve-mode census', () => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + it('classifies every public store method for reserve-mode cells', () => { + const methods = publicStoreMethods() + expect(methods.filter((name) => !(name in RESERVE_MODE_CENSUS))).toEqual([]) + expect(Object.keys(RESERVE_MODE_CENSUS).filter((name) => !methods.includes(name))).toEqual([]) + }) + + async function setup(now: () => number, reserve: string[] = []) { + database = await openInMemoryRelayDatabase() + const store = new RelayAssignmentStore(database, now) + await store.reconcileCells(CELLS) + await setReserve(store, reserve) + return store + } + + async function setReserve(store: RelayAssignmentStore, reserve: string[]) { + for (const cell of CELLS) { + await store.setCellAdmitMode(cell.id, reserve.includes(cell.id) ? 'reserve' : 'db') + } + } + + // A store that has never read the set: the table is unreadable. + async function unknownSet(now: () => number) { + const unknown = new RelayAssignmentStore(database!, now) + await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone') + return { + unknown, + restore: async () => + await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes') + } + } + + it('lets no expiry sweep touch a reserve-mode cell, and runs none while the set is unknown', async () => { + let now = 1_000 + const store = await setup(() => now) + const onA = { userId: 'user-a', relayHostId: 'host000000000001' } + expect((await store.assign(onA)).cellId).toBe('cell-a') + await store.acquireActivity(onA, { activityId: 'invite:one', kind: 'invite', cellId: 'cell-a' }) + await setReserve(store, ['cell-a']) + now += ASSIGNMENT_LIMITS.activityLeaseMs + 1 + expect(await store.releaseExpiredActivityLeases()).toBe(0) + expect(await store.releaseExpiredActivity()).toBe(0) + const { unknown, restore } = await unknownSet(() => now) + expect(await unknown.reserveModeCells()).toBeNull() + expect(await unknown.releaseExpiredActivityLeases()).toBe(0) + expect(await unknown.evacuateDeadCells()).toBe(0) + await restore() + await setReserve(store, []) + expect(await store.releaseExpiredActivityLeases()).toBeGreaterThan(0) + }) + + it('logs an unreadable reserve-mode set at most once a minute', async () => { + let now = 1_000 + await setup(() => now) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const clock = vi.spyOn(performance, 'now') + let at = 1_000_000 + clock.mockImplementation(() => at) + const { unknown, restore } = await unknownSet(() => now) + try { + for (let read = 0; read < 3; read += 1) { + expect(await unknown.reserveModeCells()).toBeNull() + at += 10_000 + } + at += 60_000 + expect(await unknown.reserveModeCells()).toBeNull() + const lines = warn.mock.calls.filter(([line]) => String(line).includes('orca_relay_reserve_mode_read_failed')) + expect(lines).toHaveLength(2) + } finally { + await restore() + clock.mockRestore() + warn.mockRestore() + } + }) + + it('tells break glass whether a cell is still heartbeating', async () => { + let now = 1_000 + const store = await setup(() => now) + expect(await store.cellHeartbeatFresh('cell-a')).toBe(false) + await store.reconcileCells([{ ...CELLS[0]!, connectionHardCap: 600, connectionUnobservedBound: 50 }, CELLS[1]!]) + await store.recordCellHeartbeat({ + cellId: 'cell-a', + cellUrl: CELLS[0]!.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: 50, + ready: false, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + // Not ready is still alive: break glass is for a cell that has gone silent. + expect(await store.cellHeartbeatFresh('cell-a')).toBe(true) + now += 45_001 + expect(await store.cellHeartbeatFresh('cell-a')).toBe(false) + }) + + it('keeps an idle host sticky on a reserve-mode cell where today it would be re-placed', async () => { + let now = 1_000 + const store = await setup(() => now) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + const first = await store.assign(identity) + await store.changeActivity(identity, 'control', -1) + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + await setReserve(store, [first.cellId]) + expect(await store.assign(identity)).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + // Not knowing which cells are switched on is treated the same way. + const { unknown, restore } = await unknownSet(() => now) + expect((await unknown.assign(identity)).assignmentEpoch).toBe(first.assignmentEpoch) + await restore() + }) + + // Only the first reserve-set read is held; it is released (or failed) by the test. + function holdFirstRead(options: { fail?: boolean } = {}) { + const query = database!.query.bind(database!) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + const counter = { reads: 0 } + database!.query = async (sql, params) => { + if (sql.includes('FROM relay_cell_admit_modes m')) { + counter.reads += 1 + if (counter.reads === 1) { + await released + if (options.fail) throw new Error('canceling statement due to statement timeout') + } + } + return await query(sql, params) + } + return { counter, release, restore: () => (database!.query = query) } + } + + it('reads afresh after an admit-mode write instead of joining a read begun before it', async () => { + const store = await setup(() => 1_000) + const held = holdFirstRead() + try { + const before = store.reserveModeCells() + await store.setCellAdmitMode('cell-a', 'reserve') + const after = await Promise.race([ + store.reserveModeCells(), + new Promise<'joined'>((resolve) => setTimeout(() => resolve('joined'), 500)) + ]) + expect(after === 'joined' ? after : [...(after ?? [])]).toEqual(['cell-a']) + held.release() + // The pre-write read lands after, and is not kept. + await before + expect([...((await store.reserveModeCells()) ?? [])]).toEqual(['cell-a']) + } finally { + held.release() + held.restore() + } + }) + + it('does not back off for a pre-write read that fails after the write', async () => { + const store = await setup(() => 1_000) + const held = holdFirstRead({ fail: true }) + try { + const before = store.reserveModeCells() + await store.setCellAdmitMode('cell-a', 'reserve') + const reads = held.counter.reads + held.release() + await before + // A write cleared the cache: the next caller reads now, not after a 5 s backoff. + expect([...((await store.reserveModeCells()) ?? [])]).toEqual(['cell-a']) + expect(held.counter.reads).toBeGreaterThan(reads) + } finally { + held.release() + held.restore() + } + }) + + // A table unreadable for over a minute leaves a stale set, not none: a host on a cell last + // known to be in database mode is placed as today, not pinned as if its cell might be reserve. + it('checks pins and dormancy against the last-known set once the read goes stale', async () => { + let now = 1_000 + const clock = vi.spyOn(performance, 'now') + let at = 5_000_000 + clock.mockImplementation(() => at) + const store = await setup(() => now) + try { + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + const first = await store.assign(identity) + await store.changeActivity(identity, 'control', -1) + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + expect([...((await store.reserveModeCells()) ?? [])]).toEqual([]) + await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone') + at += 61_000 + expect(await store.reserveModeCells()).toBeNull() + expect((await store.assign(identity)).assignmentEpoch).toBeGreaterThan(first.assignmentEpoch) + } finally { + await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes').catch(() => undefined) + clock.mockRestore() + } + }) + + it('never places a fresh host on a reserve-mode cell through the database', async () => { + const store = await setup(() => 1_000, ['cell-a']) + for (let index = 0; index < 4; index += 1) { + const placed = await store.assign({ + userId: `user-${index}`, + relayHostId: `host${String(index).padStart(12, '0')}` + }) + expect(placed.cellId).toBe('cell-b') + } + }) + + it('refuses drains, evacuations and status reads that would treat a reserve-mode cell as empty', async () => { + const store = await setup(() => 1_000, ['cell-a']) + await expect(store.cellDeploymentStatus('cell-a')).rejects.toBeInstanceOf( + RelayCellInReserveModeError + ) + await expect(store.cellEvacuationCapacity('cell-a', 'cell-b')).rejects.toThrow( + 'cell_in_reserve_mode' + ) + await expect(store.startActiveCellEvacuations('cell-b', 'cell-a', 10)).rejects.toThrow( + 'cell_in_reserve_mode' + ) + await expect( + store.prepareCellDrainAttempt({ + attemptId: '22222222-2222-4222-8222-222222222222', + cellId: 'cell-a', + cellIncarnation: '11111111-1111-4111-8111-111111111111', + traceValue: 'trace', + plannedGraceMs: 1_000 + }) + ).rejects.toThrow('cell_in_reserve_mode') + expect((await store.cellDeploymentStatus('cell-b')).cellId).toBe('cell-b') + // Unknown refuses too, with a retryable error. + const { unknown, restore } = await unknownSet(() => 1_000) + await expect(unknown.cellDeploymentStatus('cell-b')).rejects.toThrow('reserve_mode_unknown') + await restore() + }) + + it('refuses the flip to reserve while a migration names the cell', async () => { + const store = await setup(() => 1_000) + await database!.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, + source_request_units, target_reserved_units, expires_at, created_at, updated_at) + VALUES ('user-a', 'host000000000001', 'cell-b', 'cell-a', 1, 2, 0, 1, 9999, 1000, 1000)` + ) + await expect(store.setCellAdmitMode('cell-a', 'reserve')).rejects.toThrow('cell_has_open_migration') + expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'db' }) + await store.setCellAdmitMode('cell-a', 'db') + await database!.query(`UPDATE relay_assignment_migrations SET aborted_at = 1001`) + await store.setCellAdmitMode('cell-a', 'reserve') + expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'reserve', updatedAt: 1_000 }) + }) +}) + +function publicStoreMethods(): string[] { + const path = fileURLToPath(new URL('./assignment-store.ts', import.meta.url)) + const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.ES2022, true) + const names = new Set() + source.forEachChild((node) => { + if (!ts.isClassDeclaration(node) || node.name?.text !== 'RelayAssignmentStore') return + for (const member of node.members) { + if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue + const hidden = ts + .getModifiers(member) + ?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword + ) + if (!hidden) names.add(member.name.text) + } + }) + return [...names].sort() +} diff --git a/cloud/apps/relay/src/assignment-reserve-mode-postgres.test.ts b/cloud/apps/relay/src/assignment-reserve-mode-postgres.test.ts new file mode 100644 index 000000000000..442f63744c70 --- /dev/null +++ b/cloud/apps/relay/src/assignment-reserve-mode-postgres.test.ts @@ -0,0 +1,251 @@ +import { randomUUID } from 'node:crypto' +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { CELL_ADMIT_EFFECTIVE_FRESH_MS, RelayAssignmentStore } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +// The step-5 census on real PostgreSQL: every new statement (the admit-mode table, the +// open-flow check under FOR UPDATE, the live-reserve pin) must run there, not only on SQLite. + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +const run = randomUUID().slice(0, 8) +const cellA: RelayCellConfig = { id: `census-pg-a-${run}`, url: `https://census-a-${run}.example.com`, capacityRequests: 10, connectionHardCap: 600, connectionUnobservedBound: 50 } +const cellB: RelayCellConfig = { id: `census-pg-b-${run}`, url: `https://census-b-${run}.example.com`, capacityRequests: 10, connectionHardCap: 600, connectionUnobservedBound: 50 } +const USER = `census-pg-${run}` + +describePostgres('reserve-mode census on PostgreSQL', () => { + let database: RelayDatabase + + beforeAll(async () => { + database = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + }) + + afterAll(async () => { + for (const table of ['relay_assignment_activity_leases', 'relay_assignment_migrations', 'relay_assignments']) { + await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [USER]) + } + for (const cell of [cellA, cellB]) { + for (const table of ['relay_cell_admit_effective', 'relay_cell_admit_modes', 'relay_cell_admission', 'relay_cell_regions', 'relay_cell_runtime', 'relay_cells']) { + await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id]) + } + } + await database.close() + }) + + const heartbeat = async (store: RelayAssignmentStore, cell: RelayCellConfig, ready = true) => + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: 50, + ready, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + + it('flips a cell to reserve and back, and refuses reserve while a migration names it', async () => { + const store = new RelayAssignmentStore(database, () => 1_000) + await store.reconcileCells([cellA, cellB], false) + await store.setCellAdmitMode(cellA.id, 'reserve') + expect(await store.cellAdmitMode(cellA.id)).toEqual({ admitMode: 'reserve', updatedAt: 1_000 }) + expect([...((await store.reserveModeCells()) ?? [])]).toContain(cellA.id) + await store.setCellAdmitMode(cellA.id, 'db') + expect(await store.cellAdmitMode(cellA.id)).toMatchObject({ admitMode: 'db' }) + await database.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, + source_request_units, target_reserved_units, expires_at, created_at, updated_at) + VALUES (?, 'host000000000009', ?, ?, 1, 2, 0, 1, 9999, 1000, 1000)`, + [USER, cellB.id, cellA.id] + ) + await expect(store.setCellAdmitMode(cellA.id, 'reserve')).rejects.toThrow('cell_has_open_migration') + await database.query(`DELETE FROM relay_assignment_migrations WHERE user_id = ?`, [USER]) + await store.setCellAdmitMode(cellA.id, 'reserve') + await store.setCellAdmitMode(cellA.id, 'db') + }) + + it('pins a host on a live reserve cell without writing its rows, and re-places it once that cell is dead', async () => { + let now = 100_000 + const store = new RelayAssignmentStore(database, () => now, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) + await store.reconcileCells([cellA, cellB], false) + await heartbeat(store, cellA) + await heartbeat(store, cellB) + expect(await store.cellHeartbeatFresh(cellA.id)).toBe(true) + const identity = { userId: USER, relayHostId: 'host000000000001' } + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES (?, ?, ?, 4, ?, ?, 0, 0, 0, 0, 0, 0)`, + [USER, identity.relayHostId, cellA.id, now, now] + ) + await store.setCellAdmitMode(cellA.id, 'reserve') + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + await heartbeat(store, cellA) + await heartbeat(store, cellB) + const reservedBefore = await database.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [cellA.id]) + expect(await store.assign(identity)).toMatchObject({ cellId: cellA.id, assignmentEpoch: 4 }) + expect(await database.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [cellA.id])).toEqual( + reservedBefore + ) + expect( + await database.query(`SELECT activity_id FROM relay_assignment_activity_leases WHERE user_id = ?`, [USER]) + ).toEqual([]) + // cellA stops heartbeating: a dead cell holds no duplicate, so the host is re-placed. + now += 60_000 + await heartbeat(store, cellB) + expect(await store.cellHeartbeatFresh(cellA.id)).toBe(false) + expect((await store.assign(identity)).cellId).toBe(cellB.id) + await store.setCellAdmitMode(cellA.id, 'db') + }) + + // A database brownout drops ready, not the socket: a host on a reserve cell that still + // heartbeats is not re-placed (that would seat it twice); its home just reads unavailable. + it('does not re-place a host off a reserve cell that heartbeats while not ready', async () => { + let now = 400_000 + const store = new RelayAssignmentStore(database, () => now, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) + await store.reconcileCells([cellA, cellB], false) + const identity = { userId: USER, relayHostId: 'host000000000005' } + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES (?, ?, ?, 7, ?, ?, 0, 0, 0, 0, 0, 0)`, + [USER, identity.relayHostId, cellA.id, now, now] + ) + await store.setCellAdmitMode(cellA.id, 'reserve') + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + await heartbeat(store, cellA, false) + await heartbeat(store, cellB) + await expect(store.assign(identity)).rejects.toThrow('relay_home_cell_unavailable') + expect( + await database.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, [ + USER, + identity.relayHostId + ]) + ).toEqual([{ cell_id: cellA.id }]) + await store.setCellAdmitMode(cellA.id, 'db') + }) + + // A fleet-wide dead-man trip leaves every cell reserve in PG, and no director writes db back. + // The database path places on a reserve cell only by that cell's own fresh row saying db. + it('places a fresh host on a reserve cell only while its own current-process row says db', async () => { + let now = 200_000 + const fresh = () => new RelayAssignmentStore(database, () => now) + const setup = fresh() + await setup.reconcileCells([cellA, cellB], false) + await heartbeat(setup, cellA) + await heartbeat(setup, cellB) + await setup.setCellAdmitMode(cellA.id, 'reserve') + await setup.setCellAdmitMode(cellB.id, 'reserve') + // An older cell image writes no row: never admitted. + await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000002' })).rejects.toThrow( + 'relay_capacity_exhausted' + ) + // A row from another process (a restarted cell's predecessor) does not count either. + await setup.recordCellAdmitEffective({ cellId: cellB.id, cellIncarnation: '22222222-2222-4222-8222-222222222222', mode: 'db' }) + await setup.recordCellAdmitEffective({ cellId: cellA.id, cellIncarnation: '11111111-1111-4111-8111-111111111111', mode: 'reserve' }) + await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000002' })).rejects.toThrow( + 'relay_capacity_exhausted' + ) + await setup.recordCellAdmitEffective({ cellId: cellA.id, cellIncarnation: '11111111-1111-4111-8111-111111111111', mode: 'db' }) + const store = fresh() + expect((await store.assign({ userId: USER, relayHostId: 'host000000000003' })).cellId).toBe(cellA.id) + // Census sweeps still treat it as reserve. + expect([...((await store.reserveModeCells()) ?? [])]).toEqual(expect.arrayContaining([cellA.id, cellB.id])) + // Three missed 15 s refreshes and the row counts for nothing. + now += CELL_ADMIT_EFFECTIVE_FRESH_MS + 1 + await heartbeat(setup, cellA) + await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000006' })).rejects.toThrow( + 'relay_capacity_exhausted' + ) + await setup.setCellAdmitMode(cellA.id, 'db') + await setup.setCellAdmitMode(cellB.id, 'db') + }) + + // A timed-out read inside BEGIN aborts the transaction (25P02 on the next statement), so the + // reserve set is read before BEGIN and the transaction answers from that read. + it('assigns while the admit-mode read times out (57014), without aborting the transaction', async () => { + const timed = await openRelayDatabase({ databaseUrl, dataDir: '/tmp', statementTimeoutMs: 300 }) + const holder = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let held!: () => void + const holding = new Promise((resolve) => (held = resolve)) + const lock = holder.transaction(async (transaction) => { + await transaction.query(`LOCK TABLE relay_cell_admit_modes IN ACCESS EXCLUSIVE MODE`) + held() + await released + }) + try { + await holding + const store = new RelayAssignmentStore(timed, () => 300_000) + await store.reconcileCells([cellA, cellB], false) + await heartbeat(store, cellA) + await heartbeat(store, cellB) + expect(await store.reserveModeCells()).toBeNull() + const placed = await store.assign({ userId: USER, relayHostId: 'host000000000004' }) + expect([cellA.id, cellB.id]).toContain(placed.cellId) + } finally { + release() + await lock + await timed.close() + await holder.close() + } + }, 20_000) + + // A stall must not double connection demand: concurrent assigns share one reserve-set read, + // and after it fails the last answer stands for the backoff without another query. + it('reads the reserve set once for concurrent assigns during a stall, and not again in the backoff', async () => { + const timed = await openRelayDatabase({ databaseUrl, dataDir: '/tmp', statementTimeoutMs: 300 }) + const holder = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let held!: () => void + const holding = new Promise((resolve) => (held = resolve)) + const lock = holder.transaction(async (transaction) => { + await transaction.query(`LOCK TABLE relay_cell_admit_modes IN ACCESS EXCLUSIVE MODE`) + held() + await released + }) + try { + await holding + const store = new RelayAssignmentStore(timed, () => 500_000) + await store.reconcileCells([cellA, cellB], false) + await heartbeat(store, cellA) + await heartbeat(store, cellB) + const query = timed.query.bind(timed) + let reads = 0 + timed.query = async (sql, params) => { + if (sql.includes('FROM relay_cell_admit_modes m')) reads += 1 + return await query(sql, params) + } + const placed = await Promise.all( + Array.from({ length: 6 }, async (_, index) => + await store.assign({ userId: USER, relayHostId: `hoststall0000${String(index).padStart(3, '0')}` }) + ) + ) + expect(placed).toHaveLength(6) + expect(reads).toBe(1) + await store.assign({ userId: USER, relayHostId: 'hoststall0000100' }) + expect(await store.reserveModeCells()).toBeNull() + expect(reads).toBe(1) + } finally { + release() + await lock + await timed.close() + await holder.close() + } + }, 20_000) +}) + diff --git a/cloud/apps/relay/src/assignment-store-lock-order.test.ts b/cloud/apps/relay/src/assignment-store-lock-order.test.ts index e6ca46baf710..352b42305b7c 100644 --- a/cloud/apps/relay/src/assignment-store-lock-order.test.ts +++ b/cloud/apps/relay/src/assignment-store-lock-order.test.ts @@ -16,6 +16,8 @@ class LockOrderDatabase implements RelayDatabase { ) {} async query(sql: string): Promise { + // No cell is in reserve mode. + if (sql.includes('FROM relay_cell_admit_modes')) return [] if (sql.includes('SELECT user_id, relay_host_id, activity_id')) { return this.cleanupCandidate ? [ @@ -149,7 +151,8 @@ class ReassignmentLockOrderDatabase implements RelayDatabase { class AggregateCleanupDatabase implements RelayDatabase { failIfUnavailable: boolean | null = null - async query(): Promise { + async query(sql: string): Promise { + if (sql.includes('FROM relay_cell_admit_modes')) return [] return [{ changes: 1 }] } diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index d085b55dae29..e70e5dc7d10f 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -140,6 +140,25 @@ type RelayAssignmentStoreOptions = { export type { ControlRenewalOutcome, ControlRenewalRequest } +// `cellId` null: the reserve-mode set could not be read, so no cell is known to be in db mode. +export class RelayCellInReserveModeError extends Error { + constructor(readonly cellId: string | null) { + super(cellId === null ? 'reserve_mode_unknown' : 'cell_in_reserve_mode') + } +} + +// Step 5: the flag workflow records each cell's admitMode here (reserve before the cell's +// switch file, db only after the cell has re-registered its controls). Every per-host sweep +// and reader skips reserve cells, and does nothing while the set is unknown. +const RESERVE_MODE_CACHE_MS = 5_000 +const RESERVE_MODE_STALE_MS = 60_000 +// After a failed read, the last good one answers this long without a query: during a stall +// every assign would otherwise spend a read's timeout before its own transaction. +const RESERVE_MODE_FAILURE_BACKOFF_MS = 5_000 +// A cell refreshes its admit-effective row every 15 s; three missed refreshes and it counts for nothing. +export const CELL_ADMIT_EFFECTIVE_FRESH_MS = 45_000 +const RESERVE_MODE_FAILURE_LOG_MS = 60_000 + type ControlActivationInput = { cellId: string assignmentEpoch: number @@ -579,6 +598,11 @@ const ABORTABLE_EXPIRED_MIGRATION = `( OR ${ABANDONED_REGISTERED_MIGRATION} )` +// `cell_id NOT IN (...)` for the reserve-mode cells, or nothing when there are none. +function excludeCellsSql(column: string, cellIds: readonly string[]): string { + return cellIds.length === 0 ? '' : ` AND ${column} NOT IN (${cellIds.map(() => '?').join(', ')})` +} + export class RelayAssignmentStore { private readonly regionalRehomeCohortPercent: number private readonly regionalRehomeDirectorRegion?: RelayRegion @@ -593,6 +617,17 @@ export class RelayAssignmentStore { { failures: number; until: number } >() private readonly recordControlRenewal?: RelayAssignmentStoreOptions['recordControlRenewal'] + private reserveModeRead: { + cells: ReadonlySet + admitsDatabase: ReadonlySet + readAt: number + } | null = null + private reserveModeFailureLoggedAt = Number.NEGATIVE_INFINITY + private reserveModeFailedAt = Number.NEGATIVE_INFINITY + // One read at a time: concurrent callers share it. + private reserveModeInFlight: Promise | null = null + // Bumped by an admit-mode write, so a read begun before it cannot land after it. + private reserveModeGeneration = 0 private readonly placementLoadBand?: PlacementLoadBand private readonly admissionSelector: RelayCellAdmissionSelector private readonly migrationCellRegistrar: RelayMigrationCellRegistrar @@ -625,6 +660,205 @@ export class RelayAssignmentStore { this.migrationCellRegistrar = new RelayMigrationCellRegistrar(database, now) } + // Null when unreadable for a minute: callers then treat every cell as possibly reserve. + // Inside a transaction (any `database` but the pool) it answers from the last read and never + // runs a statement: a failed read there (57014) would abort the transaction, and the next + // statement's 25P02 is not retried. Callers read once before they begin. + async reserveModeCells( + database: Pick = this.database + ): Promise | null> { + const at = performance.now() + const cached = this.reserveModeRead + const answer = (): ReadonlySet | null => { + const read = this.reserveModeRead + return read && performance.now() - read.readAt <= RESERVE_MODE_STALE_MS ? read.cells : null + } + if ( + database !== this.database || + (cached && at - cached.readAt < RESERVE_MODE_CACHE_MS) || + at - this.reserveModeFailedAt < RESERVE_MODE_FAILURE_BACKOFF_MS + ) { + return answer() + } + if (!this.reserveModeInFlight) { + const read: Promise = this.readReserveModes(database).finally(() => { + if (this.reserveModeInFlight === read) this.reserveModeInFlight = null + }) + this.reserveModeInFlight = read + } + await this.reserveModeInFlight + return answer() + } + + private async readReserveModes(database: Pick): Promise { + const at = performance.now() + const generation = this.reserveModeGeneration + try { + // With each reserve cell, whether its own current process says it admits through the + // database (a tripped dead-man): the one read the database path may place on it by. + const rows = await database.query( + `SELECT m.cell_id, e.cell_id AS admits_database + FROM relay_cell_admit_modes m + LEFT JOIN relay_cell_admit_effective e + ON e.cell_id = m.cell_id + AND e.mode = 'db' + AND e.updated_at > ? + AND e.cell_incarnation = + (SELECT r.cell_incarnation FROM relay_cell_runtime r WHERE r.cell_id = m.cell_id) + WHERE m.admit_mode <> 'db'`, + [this.now() - CELL_ADMIT_EFFECTIVE_FRESH_MS] + ) + if (generation !== this.reserveModeGeneration) return + this.reserveModeRead = { + cells: new Set(rows.map((row) => text(row, 'cell_id'))), + admitsDatabase: new Set(rows.filter((row) => row.admits_database != null).map((row) => text(row, 'cell_id'))), + readAt: at + } + } catch (error) { + // A read begun before an admit-mode write says nothing about the table after it. + if (generation === this.reserveModeGeneration) this.reserveModeFailedAt = performance.now() + // Keep the last read until it is stale. Logged at most once a minute: while it fails, + // sweeps soon stop and admin operations are refused, so the cause must be on the record. + if (at - this.reserveModeFailureLoggedAt >= RESERVE_MODE_FAILURE_LOG_MS) { + this.reserveModeFailureLoggedAt = at + console.warn( + JSON.stringify({ + event: 'orca_relay_reserve_mode_read_failed', + reason: error instanceof Error ? error.message : 'unknown', + lastReadAgeMs: this.reserveModeRead ? Math.round(at - this.reserveModeRead.readAt) : null + }) + ) + } + } + } + + // Only from a recent read: a cell re-armed since may be booking from memory again. + private reserveCellAdmitsDatabase(cellId: string): boolean { + const read = this.reserveModeRead + return read !== null && performance.now() - read.readAt <= 2 * RESERVE_MODE_CACHE_MS && read.admitsDatabase.has(cellId) + } + + // Written by the cell itself, best-effort, on every change of its raw mode and every 15 s. + async recordCellAdmitEffective(input: { + cellId: string + cellIncarnation: string + mode: 'db' | 'reserve' + }): Promise { + await this.database.query( + `INSERT INTO relay_cell_admit_effective (cell_id, cell_incarnation, mode, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (cell_id) DO UPDATE SET + cell_incarnation = excluded.cell_incarnation, + mode = excluded.mode, + updated_at = excluded.updated_at`, + [input.cellId, input.cellIncarnation, input.mode, this.now()] + ) + } + + // Break glass flips only a cell that has stopped heartbeating, ready or not. + async cellHeartbeatFresh(cellId: string): Promise { + return await this.heartbeatFresh(this.database, cellId, this.now()) + } + + private async heartbeatFresh(database: Pick, cellId: string, now: number): Promise { + const rows = await database.query( + `SELECT cell_id FROM relay_cell_runtime WHERE cell_id = ? AND last_heartbeat_at > ?`, + [cellId, now - this.heartbeatTtlMs] + ) + return rows.length === 1 + } + + // A reserve cell still heartbeating holds its hosts in memory, ready or not (a database + // brownout drops ready): the same test as break glass, so neither re-places open sockets. + private async liveReserveCell( + cellId: string, + database: RelayDatabase, + now: number + ): Promise { + if (!(await this.mayBeReserveCell(cellId, database))) return false + return !this.requireLiveCells || (await this.heartbeatFresh(database, cellId, now)) + } + + // A stale set beats none: an unreadable table for a minute must not pin every host. Only a + // store that has never read the set treats every cell as possibly reserve. + private async mayBeReserveCell( + cellId: string, + database: Pick = this.database + ): Promise { + const cells = (await this.reserveModeCells(database)) ?? this.reserveModeRead?.cells ?? null + return cells === null || cells.has(cellId) + } + + // Ops on a reserve cell would act on rows its hosts no longer keep; unknown refuses too. + private async refuseReserveCell( + cellId: string, + database: Pick = this.database + ): Promise { + const cells = await this.reserveModeCells(database) + if (cells === null) throw new RelayCellInReserveModeError(null) + if (cells.has(cellId)) throw new RelayCellInReserveModeError(cellId) + } + + async cellAdmitMode(cellId: string): Promise<{ admitMode: 'db' | 'reserve'; updatedAt: number | null }> { + const row = ( + await this.database.query( + `SELECT admit_mode, updated_at FROM relay_cell_admit_modes WHERE cell_id = ?`, + [cellId] + ) + )[0] + return row + ? { admitMode: text(row, 'admit_mode') === 'reserve' ? 'reserve' : 'db', updatedAt: integer(row, 'updated_at') } + : { admitMode: 'db', updatedAt: null } + } + + // Reserve is refused while anything that moves this cell's rows is open: those flows read + // and write per-host rows a reserve cell no longer keeps. + async setCellAdmitMode(cellId: string, admitMode: 'db' | 'reserve'): Promise { + const now = this.now() + await this.database.transaction(async (transaction) => { + if (admitMode === 'reserve') { + // One locked read per table: Postgres refuses FOR UPDATE on a UNION. + const openFlows: Array<[string, string, unknown[]]> = [ + [ + 'migration', + `SELECT user_id FROM relay_assignment_migrations + WHERE (source_cell_id = ? OR target_cell_id = ?) + AND completed_at IS NULL AND aborted_at IS NULL LIMIT 1`, + [cellId, cellId] + ], + [ + 'rehome', + `SELECT attempt_id FROM relay_region_rehome_attempts + WHERE (source_cell_id = ? OR target_cell_id = ?) + AND completed_at IS NULL AND aborted_at IS NULL LIMIT 1`, + [cellId, cellId] + ], + [ + 'drain', + `SELECT attempt_id FROM relay_cell_drain_attempt_states + WHERE cell_id = ? AND state <> 'proven-not-delivered' + AND cell_incarnation = (SELECT cell_incarnation FROM relay_cell_runtime WHERE cell_id = ?) + LIMIT 1`, + [cellId, cellId] + ] + ] + for (const [kind, sql, params] of openFlows) { + if ((await transaction.queryLocked(sql, params))[0]) throw new Error(`cell_has_open_${kind}`) + } + } + await transaction.query( + `INSERT INTO relay_cell_admit_modes (cell_id, admit_mode, updated_at) VALUES (?, ?, ?) + ON CONFLICT (cell_id) DO UPDATE SET admit_mode = excluded.admit_mode, updated_at = excluded.updated_at`, + [cellId, admitMode, now] + ) + }) + this.reserveModeRead = null + this.reserveModeFailedAt = Number.NEGATIVE_INFINITY + this.reserveModeGeneration += 1 + // The next caller reads afresh rather than joining a read from before this write. + this.reserveModeInFlight = null + } + async reconcileCells(cells: RelayCellConfig[], disableMissing = true): Promise { await this.reconcileCellsWithOptions(cells, disableMissing) } @@ -849,6 +1083,7 @@ export class RelayAssignmentStore { preferredRegion?: RelayRegion ): Promise { const now = this.now() + await this.reserveModeCells() return await this.database.transaction(async (transaction) => { // Why: the retry exists to take a cell row before the assignment row, the // order placement uses. It only ever needs the one cell this host is @@ -884,7 +1119,13 @@ export class RelayAssignmentStore { if (!existing) return null const activityLeases = await this.lockAssignmentActivities(transaction, identity, true) await this.recordRegionPreference(transaction, identity, preferredRegion, now) - if (mayNormallyReassign(activity(existing), now)) return null + // A live reserve cell's hosts hold no activity here, so idle is not evidence they left. + if ( + !(await this.liveReserveCell(text(existing, 'cell_id'), transaction, now)) && + mayNormallyReassign(activity(existing), now) + ) { + return null + } const pinnedAdmission = text(existing, 'cell_id') === pinnedCellId ? unlockedAdmission @@ -956,6 +1197,17 @@ export class RelayAssignmentStore { ) { return null } + const leaseExpiresAt = now + ASSIGNMENT_LIMITS.activityLeaseMs + // A reserve cell counts its own controls: the pin is returned with no row written, + // since the sweeps that would release those units skip this cell. + if (!hadControl && (await this.mayBeReserveCell(currentCellId, transaction))) { + return this.result( + identity, + existing, + cell(currentRow, await this.cellRegion(transaction, currentCellId)), + leaseExpiresAt + ) + } if ( !hadControl && !(await this.cellHasConnectionHeadroom(transaction, currentCellId)) @@ -964,7 +1216,6 @@ export class RelayAssignmentStore { throw new Error('relay_connection_headroom_exhausted') } - const leaseExpiresAt = now + ASSIGNMENT_LIMITS.activityLeaseMs if (hadControl) { await this.touchAssignment(transaction, identity, leaseExpiresAt, now) } else { @@ -1059,6 +1310,7 @@ export class RelayAssignmentStore { // so a lock timeout there must retry the same tier, not fall to 'all'. let retryScope: RetriedAssignmentInventoryScope = inventoryScope === 'none' ? 'general' : inventoryScope + await this.reserveModeCells() // Why the events ride back out rather than being written where they are // decided: everything below runs in one transaction, and a reservation or // lease write that fails after the decision rolls the placement back. A @@ -1093,7 +1345,12 @@ export class RelayAssignmentStore { const existing = await this.assignmentRowOrBusy(transaction, identity) // A dormant host holds no units, so its placement never writes its old // cell and needs only the rows it could land on. - const dormant = !existing || mayNormallyReassign(activity(existing), now) + // A live reserve cell's hosts hold no activity rows here: idle there is not dormant. A + // dead one holds no duplicate, so its hosts are re-placed as any dormant host is. + const dormant = + !existing || + (mayNormallyReassign(activity(existing), now) && + !(await this.liveReserveCell(text(existing, 'cell_id'), transaction, now))) retryScope = dormant ? 'general' : isolatedScope ? inventoryScope : 'all' if ( (inventoryScope === 'general' && !dormant) || @@ -1213,6 +1470,19 @@ export class RelayAssignmentStore { current.cellId, integer(existing, 'assignment_epoch') ) + // A reserve cell counts its own controls: the pin is returned with no row written, + // since the sweeps that would release those units skip this cell. + if (!hadControl && (await this.mayBeReserveCell(current.cellId, transaction))) { + return { + assignment: this.result( + identity, + existing, + current, + now + ASSIGNMENT_LIMITS.activityLeaseMs + ), + events + } + } const hasConnectionHeadroom = hadControl || (await this.cellHasConnectionHeadroom(transaction, current.cellId)) @@ -2347,6 +2617,8 @@ export class RelayAssignmentStore { traceValue: string plannedGraceMs: number }): Promise { + // Rolling a switched-on cell needs a flip back first (design default 4). + await this.refuseReserveCell(input.cellId) const now = this.now() return await this.database.transaction(async (transaction) => { await this.assertDrainCellGeneration( @@ -3022,7 +3294,13 @@ export class RelayAssignmentStore { async evacuateDeadCells(limit = 100): Promise { if (!this.requireLiveCells) return 0 const cutoff = this.now() - this.heartbeatTtlMs - const cellIds = await this.deadCellEvacuationCandidates(cutoff) + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return 0 + // A reserve-mode cell that stops heartbeating is unverifiable, never gone: its hosts + // re-place themselves when their control drops. + const cellIds = (await this.deadCellEvacuationCandidates(cutoff)).filter( + (cellId) => !reserveCells.has(cellId) + ) // Why: without a candidate cell the host query below walks every assignment by primary key to // return nothing (574 ms per call in production, from stale existing-only cells it can never act on). if (cellIds.length === 0) return 0 @@ -3242,6 +3520,8 @@ export class RelayAssignmentStore { targetCellId: string, limit: number ): Promise { + await this.refuseReserveCell(sourceCellId) + await this.refuseReserveCell(targetCellId) const rows = await this.database.query( `SELECT user_id, relay_host_id FROM relay_assignments WHERE cell_id = ? AND @@ -3266,6 +3546,8 @@ export class RelayAssignmentStore { sourceCellId: string, targetCellId: string ): Promise { + await this.refuseReserveCell(sourceCellId) + await this.refuseReserveCell(targetCellId) const cells = await this.database.query( `SELECT * FROM relay_cells WHERE cell_id IN (?, ?) ORDER BY cell_id`, [sourceCellId, targetCellId] @@ -3308,6 +3590,8 @@ export class RelayAssignmentStore { targetCellId: string, completeReady: boolean ): Promise { + await this.refuseReserveCell(sourceCellId) + await this.refuseReserveCell(targetCellId) let completed = 0 let blocked = 0 const sourceFenced = await this.cellHasActiveFence(sourceCellId) @@ -3496,7 +3780,9 @@ export class RelayAssignmentStore { return completed } + // Its host counts here are stale for a reserve-mode cell, so tooling must not read it as empty. async cellDeploymentStatus(cellId: string): Promise { + await this.refuseReserveCell(cellId) const cellRow = ( await this.database.query( `WITH activity AS ( @@ -3870,7 +4156,13 @@ export class RelayAssignmentStore { selectionMs: performance.now() - startedAt, skippedOffRegionSourceCells: selection.skippedOffRegionSourceCells }) - return selection.candidates + // Neither side of an idle rehome may be a reserve-mode cell: the commit moves rows here. + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return [] + return selection.candidates.filter( + (candidate) => + !reserveCells.has(candidate.sourceCellId) && !reserveCells.has(candidate.targetCellId) + ) } async commitIdleRegionalRehome( @@ -4474,6 +4766,7 @@ export class RelayAssignmentStore { expectedSourceCellId?: string ): Promise { const now = this.now() + await this.reserveModeCells() return await this.database.transaction(async (transaction) => { const assignment = await this.assignmentRow(transaction, identity) if (!assignment) throw new Error('assignment_not_found') @@ -4497,6 +4790,8 @@ export class RelayAssignmentStore { return migration(identity, existing) } if (sourceCellId === targetCellId) throw new Error('target_matches_source') + await this.refuseReserveCell(sourceCellId, transaction) + await this.refuseReserveCell(targetCellId, transaction) await this.lockAssignmentActivities(transaction, identity) await this.lockControlConnectionReservations(transaction, identity) const cells = await this.lockCellInventory(transaction, 'request') @@ -4702,7 +4997,12 @@ export class RelayAssignmentStore { }, inventoryFirst: boolean ): Promise { + // A heartbeating reserve source keeps no activity rows, so its hosts are not provably off + // it. A dead one holds no duplicate. const now = this.now() + if (await this.cellIsLive(this.database, input.sourceCellId, now)) { + await this.refuseReserveCell(input.sourceCellId) + } return await this.database.transaction(async (transaction) => { await this.lockControlConnectionReservations(transaction, identity) let lockedCells: SqlRow[] | undefined @@ -5582,6 +5882,7 @@ export class RelayAssignmentStore { assignmentEpoch: number ): Promise { const now = this.now() + await this.reserveModeCells() await this.database.transaction(async (transaction) => { const assignment = await this.assignmentRow(transaction, identity) const row = ( @@ -5608,6 +5909,8 @@ export class RelayAssignmentStore { const activityLeases = await this.lockAssignmentActivities(transaction, identity) const sourceUnits = activityUnitsForCell(activityLeases, sourceCellId) if (sourceUnits > 0) throw new Error('migration_source_still_active') + // A reserve source keeps no activity rows, so "no source units" proves nothing there. + await this.refuseReserveCell(sourceCellId, transaction) let cellsLocked = false let targetStartedAt = 0 if (this.requireLiveCells) { @@ -5684,12 +5987,15 @@ export class RelayAssignmentStore { targetCellId: string ): Promise { const now = this.now() + await this.reserveModeCells() return await this.database.transaction(async (transaction) => { const assignment = await this.assignmentRow(transaction, identity) if (!assignment) throw new Error('assignment_not_found') if (!mayNormallyReassign(activity(assignment), now)) throw new Error('assignment_active') const sourceCellId = text(assignment, 'cell_id') if (sourceCellId === targetCellId) throw new Error('target_matches_source') + await this.refuseReserveCell(sourceCellId, transaction) + await this.refuseReserveCell(targetCellId, transaction) await this.lockAssignmentActivities(transaction, identity) await this.lockControlConnectionReservations(transaction, identity) const cells = await this.lockCellInventory(transaction, 'request') @@ -7387,6 +7693,8 @@ export class RelayAssignmentStore { } async releaseExpiredActivityLeases(): Promise { + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return 0 const now = this.now() await this.database.query( `UPDATE relay_control_connection_reservations @@ -7405,7 +7713,7 @@ export class RelayAssignmentStore { const candidates = await this.database.query( `SELECT user_id, relay_host_id, activity_id FROM relay_assignment_activity_leases lease - WHERE expires_at <= ? + WHERE expires_at <= ?${excludeCellsSql('lease.cell_id', [...reserveCells])} AND NOT EXISTS ( SELECT 1 FROM relay_region_rehome_attempts rehome WHERE rehome.user_id = lease.user_id @@ -7431,7 +7739,7 @@ export class RelayAssignmentStore { 'migration:' || CAST(pin.assignment_epoch AS TEXT) ) )`, - [now] + [now, ...reserveCells] ) let released = 0 for (const candidate of candidates) { @@ -7466,13 +7774,18 @@ export class RelayAssignmentStore { } async releaseExpiredActivity(): Promise { + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return 0 const now = this.now() try { return await this.database.transaction(async (transaction) => { // Only counters no lease backs. A host's leases can sit on more than // one cell, and the lease sweep takes each one's units off its own. const expired = await transaction.queryLocked( - `SELECT * FROM relay_assignments WHERE lease_expires_at <= ? AND + `SELECT * FROM relay_assignments WHERE lease_expires_at <= ?${excludeCellsSql( + 'relay_assignments.cell_id', + [...reserveCells] + )} AND (reserved_controls > 0 OR reserved_splices > 0 OR reserved_invites > 0 OR pending_installs > 0 OR pending_confirmations > 0 OR migration_leases > 0) AND NOT EXISTS ( @@ -7498,7 +7811,7 @@ export class RelayAssignmentStore { AND migration.aborted_at IS NULL ) ORDER BY user_id, relay_host_id`, - [now], + [now, ...reserveCells], { failIfUnavailable: true } ) if (expired.length > 0) await this.lockCellInventory(transaction, 'nowait') @@ -7858,9 +8171,15 @@ export class RelayAssignmentStore { ) : null const connectionHeadroom = await this.connectionHeadroomByCell(database) + // The database pool: a reserve-mode cell takes only the bookings it admits itself. Unknown + // keeps the last set read (stale), not none: placement must not stop with a slow read. + const reserveCells = (await this.reserveModeCells(database)) ?? this.reserveModeRead?.cells ?? new Set() const candidates = rows.filter((row) => { const cellId = text(row, 'cell_id') return ( + // A reserve cell whose own feed says it admits through the database again (its dead-man + // tripped, or a flip back finished) takes placements; the census sweeps still skip it. + (!reserveCells.has(cellId) || this.reserveCellAdmitsDatabase(cellId)) && admission.get(cellId) === 'general' && integer(row, 'reserved_requests') < integer(row, 'capacity_requests') && (!runtimeLoad || runtimeLoad.has(cellId)) && @@ -7896,7 +8215,7 @@ export class RelayAssignmentStore { const rows = await this.database.query( `SELECT cell.cell_id, cell.cell_url, cell.capacity_requests, region.region, admission.admission_state, admission.roll_isolated_at, runtime.ready, - runtime.last_heartbeat_at + runtime.last_heartbeat_at, runtime.cell_incarnation 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 @@ -7924,7 +8243,8 @@ export class RelayAssignmentStore { general: optionalText(row, 'admission_state') === 'general' && optionalInteger(row, 'roll_isolated_at') === undefined && - integer(row, 'capacity_requests') > 0 + integer(row, 'capacity_requests') > 0, + ...incarnationField(optionalText(row, 'cell_incarnation')) } }) } @@ -9504,6 +9824,10 @@ function optionalInteger(row: SqlRow, field: string): number | undefined { return integer(row, field) } +function incarnationField(incarnation: string | undefined): { incarnation?: string } { + return incarnation === undefined ? {} : { incarnation } +} + function optionalText(row: SqlRow, field: string): string | undefined { if (row[field] === null || row[field] === undefined) return undefined return text(row, field) diff --git a/cloud/apps/relay/src/cell-admit-effective-writer.test.ts b/cloud/apps/relay/src/cell-admit-effective-writer.test.ts new file mode 100644 index 000000000000..2b345766759d --- /dev/null +++ b/cloud/apps/relay/src/cell-admit-effective-writer.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { CELL_ADMIT_EFFECTIVE_REFRESH_MS, CellAdmitEffectiveWriter } from './cell-admit-effective-writer.js' + +function setup(options: { fail?: boolean; busy?: boolean } = {}) { + const clock = { value: 1_000 } + const writes: Array<'db' | 'reserve'> = [] + const pending: Array<() => void> = [] + const logs: string[] = [] + const writer = new CellAdmitEffectiveWriter({ + write: async (mode) => { + writes.push(mode) + await new Promise((resolve) => pending.push(resolve)) + if (options.fail) throw new Error('connection refused') + }, + databaseBusy: () => options.busy === true, + now: () => clock.value, + log: (line) => logs.push(line) + }) + const settle = async () => { + while (pending.length > 0) pending.shift()!() + await new Promise((resolve) => setTimeout(resolve, 0)) + } + return { clock, writes, logs, writer, settle } +} + +describe('CellAdmitEffectiveWriter', () => { + it('writes on the first tick, on every change, and every 15 s, one write at a time', async () => { + const { clock, writes, writer, settle } = setup() + writer.tick('reserve', true) + writer.tick('db', true) + expect(writes).toEqual(['reserve']) + await settle() + // Tripped: the switch still says reserve, the raw mode db. + writer.tick('db', true) + await settle() + writer.tick('db', true) + expect(writes).toEqual(['reserve', 'db']) + clock.value += CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', true) + expect(writes).toEqual(['reserve', 'db', 'db']) + }) + + // Step 5 off: a db switch never writes, so the dark roll adds no database write. + it('writes nothing while the switch has never said reserve, and one db write on the way out', async () => { + const { clock, writes, writer, settle } = setup() + writer.tick('db', false) + clock.value += 10 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', false) + expect(writes).toEqual([]) + writer.tick('reserve', true) + await settle() + writer.tick('reserve', false) + await settle() + expect(writes).toEqual(['reserve', 'db']) + clock.value += 10 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', false) + expect(writes).toEqual(['reserve', 'db']) + }) + + it('owes nothing more once a tripped cell already wrote db', async () => { + const { clock, writes, writer, settle } = setup() + writer.tick('db', true) + await settle() + clock.value += CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', false) + writer.tick('db', false) + expect(writes).toEqual(['db']) + }) + + it('takes no connection while requests wait for the pool', () => { + const { writes, writer } = setup({ busy: true }) + writer.tick('db', true) + expect(writes).toEqual([]) + }) + + // Best-effort: a failed write never throws into the cell, logs at most once a minute, and retries. + it('retries a failed write on the next tick and rate-limits its log line', async () => { + const { clock, writes, logs, writer, settle } = setup({ fail: true }) + writer.tick('db', true) + await settle() + clock.value += 1_000 + writer.tick('db', true) + await settle() + expect(writes).toEqual(['db', 'db']) + expect(logs).toHaveLength(1) + expect(JSON.parse(logs[0]!)).toMatchObject({ event: 'orca_relay_cell_admit_effective_write_failed', mode: 'db' }) + }) +}) diff --git a/cloud/apps/relay/src/cell-admit-effective-writer.ts b/cloud/apps/relay/src/cell-admit-effective-writer.ts new file mode 100644 index 000000000000..1250f1770aaf --- /dev/null +++ b/cloud/apps/relay/src/cell-admit-effective-writer.ts @@ -0,0 +1,63 @@ +// The cell's raw admit mode in Postgres (relay_cell_admit_effective), so the database path can +// place on a tripped reserve cell even when no director's feed poll reaches it. Only the database +// path's PG-reserve cells need a row, so a cell writes only while its switch says reserve, plus +// one db write on the way out: with step 5 off, no cell writes anything. +export const CELL_ADMIT_EFFECTIVE_REFRESH_MS = 15_000 +const CELL_ADMIT_EFFECTIVE_FAILURE_LOG_MS = 60_000 + +export class CellAdmitEffectiveWriter { + private written: { mode: 'db' | 'reserve'; at: number } | null = null + private inFlight = false + // A reserve row may stand: the db write that replaces it is still owed. + private owed = false + private failureLoggedAt = Number.NEGATIVE_INFINITY + + constructor( + private readonly input: { + write: (mode: 'db' | 'reserve') => Promise + // Never takes a connection a waiting request needs; the next tick tries again. + databaseBusy: () => boolean + now: () => number + log?: (line: string) => void + } + ) {} + + // Best-effort: never awaited, never throws, at most one write in flight. `switchReserve` is the + // applied switch file's admitMode (a tripped dead-man keeps it reserve, with `mode` db). + tick(rawMode: 'db' | 'reserve', switchReserve: boolean): void { + if (switchReserve) this.owed = true + else if (!this.owed) return + else if (this.written?.mode === 'db' && !this.inFlight) { + this.owed = false + return + } + const mode = switchReserve ? rawMode : 'db' + const at = this.input.now() + const due = + this.written === null || + this.written.mode !== mode || + at - this.written.at >= CELL_ADMIT_EFFECTIVE_REFRESH_MS + if (!due || this.inFlight || this.input.databaseBusy()) return + this.inFlight = true + void this.input + .write(mode) + .then(() => { + this.written = { mode, at } + if (!switchReserve) this.owed = false + }) + .catch((error: unknown) => { + if (at - this.failureLoggedAt < CELL_ADMIT_EFFECTIVE_FAILURE_LOG_MS) return + this.failureLoggedAt = at + ;(this.input.log ?? ((line: string) => console.warn(line)))( + JSON.stringify({ + event: 'orca_relay_cell_admit_effective_write_failed', + mode, + reason: error instanceof Error ? error.message : 'unknown' + }) + ) + }) + .finally(() => { + this.inFlight = false + }) + } +} diff --git a/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts b/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts index 2b039af92f23..71500d481762 100644 --- a/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts +++ b/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' +import { CELL_ADMIT_EFFECTIVE_REFRESH_MS } from './cell-admit-effective-writer.js' import { RESERVE_DEAD_MAN_MS, reserveDeadManWindowMs } from './cell-reserve-dead-man.js' import { openRelayDatabase } from './database.js' import { @@ -34,7 +35,7 @@ describe('flipping a reserve-mode cell back to the database', () => { vi.restoreAllMocks() }) - async function cell(now?: () => number) { + async function cell(now?: () => number, options: { flagsUnread?: boolean } = {}) { vi.spyOn(console, 'log').mockImplementation(() => undefined) vi.spyOn(console, 'warn').mockImplementation(() => undefined) vi.spyOn(console, 'error').mockImplementation(() => undefined) @@ -42,7 +43,7 @@ describe('flipping a reserve-mode cell back to the database', () => { cleanup.push(() => rmSync(dataDir, { recursive: true, force: true })) const database = await openRelayDatabase({ dataDir }) cleanup.push(() => database.close()) - return await startReserveModeCell({ database, dataDir, cleanup, ...(now ? { now } : {}) }) + return await startReserveModeCell({ database, dataDir, cleanup, ...options, ...(now ? { now } : {}) }) } it('registers every memory-admitted control without disconnecting any, matching the seat report', async () => { @@ -228,11 +229,33 @@ describe('flipping a reserve-mode cell back to the database', () => { expect(reply.socket.readyState).toBe(WebSocket.OPEN) }, 30_000) + // Step 5 off stays write-free: a db switch (read, generation > 0) writes no row, nor does a + // booted cell before its first flag read. Leaving reserve writes db once, then nothing. + it('writes an admit-effective row only while its switch says reserve, plus one db write on the way out', async () => { + let now = Date.now() + const booting = await cell(() => now, { flagsUnread: true }) + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(await booting.admitEffectiveRow()).toBeUndefined() + booting.setAdmitMode('db') + now += 3 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(await booting.admitEffectiveRow()).toBeUndefined() + booting.setAdmitMode('reserve') + await until(async () => (await booting.admitEffectiveRow())?.mode === 'reserve') + booting.setAdmitMode('db') + await until(async () => (await booting.admitEffectiveRow())?.mode === 'db') + const last = (await booting.admitEffectiveRow())!.updated_at + now += 3 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect((await booting.admitEffectiveRow())!.updated_at).toEqual(last) + }, 30_000) + it('flips itself back to db when no placing director has been in touch for the dead-man window', async () => { let now = Date.now() const reserve = await cell(() => now) const seated = await reserve.bookedHost(3) expect(reserve.relay.sessions.inReserveMode()).toBe(true) + await until(async () => (await reserve.admitEffectiveRow())?.mode === 'reserve') const windowMs = reserveDeadManWindowMs(reserve.config.cellId) expect(windowMs).toBeGreaterThanOrEqual(RESERVE_DEAD_MAN_MS) expect(windowMs).toBeLessThanOrEqual(2 * RESERVE_DEAD_MAN_MS) @@ -252,6 +275,10 @@ describe('flipping a reserve-mode cell back to the database', () => { .mock.calls.map(([line]) => String(line)) .filter((line) => line.includes('orca_relay_cell_reserve_dead_man_tripped')) expect(tripped.map((line) => JSON.parse(line).cellId)).toEqual([reserve.config.cellId]) + // The cell's own row says db at once, while Postgres still records reserve: the database + // path places on it by this row, whether or not any director's feed poll gets through. + await until(async () => (await reserve.admitEffectiveRow())?.mode === 'db') + expect((await reserve.admitEffectiveRow())?.cell_incarnation).toBe(reserve.relay.cellIncarnation) await until(async () => (await reserve.controlLeases()).length === 2) expect(seated.socket.readyState).toBe(WebSocket.OPEN) // Latched for this switch generation: contact again does not bring reserve back. diff --git a/cloud/apps/relay/src/cell-seat-feed-route.ts b/cloud/apps/relay/src/cell-seat-feed-route.ts index 422437bbb214..59cc6f51365c 100644 --- a/cloud/apps/relay/src/cell-seat-feed-route.ts +++ b/cloud/apps/relay/src/cell-seat-feed-route.ts @@ -22,6 +22,8 @@ export function registerCellSeatFeedRoute( // A placing director's poll (reserver=1) feeds the cell's dead-man. onReserverPoll?: () => void admitModeEffective?: () => 'db' | 'reserve' + // Switch file and dead-man only: db the moment the cell trips, while re-registration runs. + admitModeRaw?: () => 'db' | 'reserve' // Step 5: what a director needs to estimate a seat and a booking here. reserveCounts?: () => { bookings: number @@ -74,6 +76,7 @@ export function registerCellSeatFeedRoute( // Applied, never desired: generation 0 means no object has been read since boot. ...(input.cellFlags ? { flagsApplied: input.cellFlags() } : {}), ...(input.admitModeEffective ? { admitModeEffective: input.admitModeEffective() } : {}), + ...(input.admitModeRaw ? { admitModeRaw: input.admitModeRaw() } : {}), ...page }) }) diff --git a/cloud/apps/relay/src/database.test.ts b/cloud/apps/relay/src/database.test.ts index bfeff59c69f6..b4aa9c13a23b 100644 --- a/cloud/apps/relay/src/database.test.ts +++ b/cloud/apps/relay/src/database.test.ts @@ -62,6 +62,8 @@ describe('relay database', () => { 'relay_assignments', 'relay_audit_events', 'relay_cell_admission', + 'relay_cell_admit_effective', + 'relay_cell_admit_modes', 'relay_cell_capabilities', 'relay_cell_committed_fences', 'relay_cell_connection_limits', diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index fa1ac57acadd..8569fcca53c9 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -359,6 +359,22 @@ CREATE TABLE IF NOT EXISTS relay_cell_admission ( roll_isolated_at BIGINT ); +-- Step 5: written by the flag workflow. A cell with no row is in database mode. +CREATE TABLE IF NOT EXISTS relay_cell_admit_modes ( + cell_id TEXT PRIMARY KEY, + admit_mode TEXT NOT NULL CHECK (admit_mode IN ('db', 'reserve')), + updated_at BIGINT NOT NULL +); + +-- Step 5: written by each cell, its raw mode (switch file and dead-man latch). The database +-- path places on a reserve cell only while this row, from its current process, says db. +CREATE TABLE IF NOT EXISTS relay_cell_admit_effective ( + cell_id TEXT PRIMARY KEY, + cell_incarnation TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('db', 'reserve')), + updated_at BIGINT NOT NULL +); + CREATE TABLE IF NOT EXISTS relay_admission_selectors ( selector_id TEXT PRIMARY KEY, generation BIGINT NOT NULL, diff --git a/cloud/apps/relay/src/floating-promise-census.test.ts b/cloud/apps/relay/src/floating-promise-census.test.ts index 19fd403a8dbd..fcc056a54462 100644 --- a/cloud/apps/relay/src/floating-promise-census.test.ts +++ b/cloud/apps/relay/src/floating-promise-census.test.ts @@ -14,6 +14,7 @@ const NEVER_REJECTS = new Map([ ['cell-heartbeat-client.ts#send', 'one try/catch around the whole send'], ['cell-reserve-client.ts#send', 'one try/catch around the request; resolves every item'], ['cell-reserve-client.ts#demote', 'one try/catch around the request'], + ['reserve-ledger-writer.ts#flush', 'one try/catch around the write; retries with backoff'], ['control-renewal-batch.ts#flush', 'rejects the waiters, never itself'], ['regional-rehome-worker.ts#run', 'one try/catch around the whole poll'], ['relay-control-flag-channel.ts#poll', 'one catch on the whole read'] diff --git a/cloud/apps/relay/src/idle-regional-rehome-selection.ts b/cloud/apps/relay/src/idle-regional-rehome-selection.ts index bed205273868..f97570164162 100644 --- a/cloud/apps/relay/src/idle-regional-rehome-selection.ts +++ b/cloud/apps/relay/src/idle-regional-rehome-selection.ts @@ -300,7 +300,7 @@ function inlineRows(columns: ReadonlyArray, rows: num } function hostKey(row: SqlRow): string { - return `${String(row.user_id)}${String(row.relay_host_id)}` + return `${String(row.user_id)}\u0000${String(row.relay_host_id)}` } function idleRehomeCandidate( diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index d5c6b1d5b81a..4ba197852d3e 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -19,6 +19,7 @@ import { createRemoteJWKSet } from 'jose' import { WebSocketServer } from 'ws' import type WebSocket from 'ws' import type { RawData } from 'ws' +import { CellAdmitEffectiveWriter } from './cell-admit-effective-writer.js' import { ReserveDeadMan, reserveDeadManWindowMs } from './cell-reserve-dead-man.js' import { createRelayApp } from './app.js' import { classifyAssignmentLease } from './assignment-lease.js' @@ -51,6 +52,11 @@ import { CellReserveClient } from './cell-reserve-client.js' import { googleMetadataIdentityToken, reusedIdentityToken } from './google-metadata-identity-token.js' import { ReserveAssignment } from './reserve-assignment.js' import { ReservePlacer } from './reserve-placement.js' +import { + reconcileReserveLedger, + RESERVE_LEDGER_RECONCILE_MS, + ReserveLedgerWriter +} from './reserve-ledger-writer.js' import { ProcessQueuedByteBudget } from './splice-forwarder.js' // A malformed percent-escape in the request target must be a client error, never a URIError @@ -187,6 +193,11 @@ export function createRelayServer( // Step 5 on directors: needs the map and the rehome credential the cells verify. const directorId = randomUUID() const rehomeAudience = config.rehomeAudience + // Only a director that books writes rows, and only for the joins it booked. + const reserveLedger = + config.role === 'director' && shadowSeatPoller && config.reservePlacement === 'on' + ? new ReserveLedgerWriter(observedDatabase, { now: options.now }) + : undefined const reservePlacement = config.role === 'director' && shadowSeatPoller && @@ -206,6 +217,7 @@ export function createRelayServer( ) }), readRow: async (identity) => await assignments.hostWhereabouts(identity), + databaseBusy: () => readRelayDatabasePoolPressure(database).databasePoolWaiting > 0, now: options.now, random: options.random }) @@ -230,6 +242,68 @@ export function createRelayServer( }, 60_000) : null offWithReserveTimer?.unref() + if (reserveLedger && shadowSeatPoller) { + const directory = shadowSeatPoller.directory + directory.onBookedJoin = (join) => { + if (join.reservedBy !== directorId) return + reserveLedger.enqueue({ + userId: join.userId, + relayHostId: join.relayHostId, + cellId: join.cellId, + epoch: join.epoch + }) + } + reserveLedger.start() + const reconcile = (): void => { + const now = (options.now ?? Date.now)() + void reconcileReserveLedger({ + writer: reserveLedger, + seats: () => + directory + .cellIds() + .filter((cellId) => directory.admitModeOf(cellId) === 'reserve') + .flatMap((cellId) => + directory.seatsOnCell(cellId).map(({ userId, relayHostId, seat }) => ({ + userId, + relayHostId, + cellId, + epoch: seat.epoch, + joinedAt: seat.joinedAt + })) + ), + demote: (seat, row) => { + reservePlacement?.demoteSeat(seat) + // The re-assign mints above the row, so the mirror can take it. + reservePlacement?.raiseEpochFloor(seat, row.epoch) + console.warn( + JSON.stringify({ + event: 'orca_relay_reserve_ledger_seat_behind_row', + cellId: seat.cellId, + epoch: seat.epoch, + rowEpoch: row.epoch + }) + ) + }, + now + }) + .catch(() => console.warn('[orca-relay] reserve ledger reconcile deferred')) + .finally(schedule) + } + // Jittered, so five directors do not reconcile together. + const schedule = (): void => { + const random = options.random ?? Math.random + setTimeout(reconcile, RESERVE_LEDGER_RECONCILE_MS + random() * 60_000).unref?.() + } + // A restart loses the queue: the first run goes as soon as the map is complete. + const clock = options.now ?? Date.now + const bootedAt = clock() + const firstRun = setInterval(() => { + if (!directory.isComplete() && clock() - bootedAt < RESERVE_LEDGER_RECONCILE_MS) return + clearInterval(firstRun) + reconcile() + }, 1_000) + firstRun.unref?.() + } const configuredConnectionLimits = config.connectionHardCap === undefined ? null @@ -311,6 +385,15 @@ export function createRelayServer( // Expired bookings give their units back even while no director is reserving. A flip out of // reserve mode voids the bookings and registers every control admitted from memory. let appliedAdmitMode = effectiveAdmitMode() + const admitEffective = + config.role === 'cell' && reserveBook + ? new CellAdmitEffectiveWriter({ + write: async (mode) => + await assignments.recordCellAdmitEffective({ cellId: config.cellId, cellIncarnation, mode }), + databaseBusy: () => readRelayDatabasePoolPressure(database).databasePoolWaiting > 0, + now: options.now ?? Date.now + }) + : undefined const reserveSweepTimer = reserveBook ? setInterval(() => { reserveBook.sweep() @@ -320,6 +403,8 @@ export function createRelayServer( sessions.reregisterMemoryControls() } appliedAdmitMode = admitMode + // A booted cell runs db defaults until its first flag read, so it writes nothing until then. + admitEffective?.tick(admitMode, options.cellFlags?.().flags.admitMode === 'reserve') }, RESERVE_MODE_WATCH_MS) : null reserveSweepTimer?.unref() @@ -351,6 +436,7 @@ export function createRelayServer( cellReserverPoll: () => reserveDeadMan?.contact(), // Reserve until every control it admitted from memory holds a lease again: the flag // workflow records db in Postgres (and sweeps resume) only after this says db. + cellAdmitModeRaw: effectiveAdmitMode, cellAdmitModeEffective: (): 'db' | 'reserve' => effectiveAdmitMode() === 'reserve' || sessions.reregistrationPending() > 0 ? 'reserve' diff --git a/cloud/apps/relay/src/reserve-assignment.test.ts b/cloud/apps/relay/src/reserve-assignment.test.ts index c48d8f3a4ac4..56c465fd0d53 100644 --- a/cloud/apps/relay/src/reserve-assignment.test.ts +++ b/cloud/apps/relay/src/reserve-assignment.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import type { CellReserveClient } from './cell-reserve-client.js' -import { ReserveAssignment, ReservePaceError, RESERVE_STARTUP_GATE_MS } from './reserve-assignment.js' +import { + ReserveAssignment, + ReservePaceError, + RESERVE_ROW_FLOOR_TTL_MS, + RESERVE_WRONG_CELL_ROW_READ_MS, + RESERVE_STARTUP_GATE_MS +} from './reserve-assignment.js' import { ReservePlacer, type ReserveAttempt } from './reserve-placement.js' import { ShadowSeatDirectory, type SeatFeedCell, type SeatFeedResponse } from './shadow-seat-directory.js' @@ -43,6 +49,7 @@ function setup( feeds?: SeatFeedResponse[] reserve?: (cellId: string, sticky: boolean) => ReserveAttempt row?: (identity: { userId: string; relayHostId: string }) => Promise<{ cellId: string; assignmentEpoch: number } | null> + databaseBusy?: () => boolean mode?: 'off' | 'dry-run' | 'on' startedAt?: number } = {} @@ -78,6 +85,7 @@ function setup( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above. client: client as unknown as CellReserveClient, readRow: options.row ?? (async () => null), + ...(options.databaseBusy ? { databaseBusy: options.databaseBusy } : {}), now: () => now.value, random: () => 0.25, log: () => undefined @@ -88,7 +96,7 @@ function setup( for (const response of options.feeds ?? cells.map((cell) => feed(cell.cellId))) { directory.apply(response.cellId, { ...response, full: response.full ?? [] }, now.value - 1, now.value - 1) } - return { assignment, directory, reserved, demoted, now } + return { assignment, directory, reserved, demoted, now, placer } } function seated(epoch: number, joinedAt = 0) { @@ -113,6 +121,17 @@ describe('reserve assignment on a director', () => { expect(await tripped.assignment.plan(HOST, { reconnect: false, region: US })).toEqual({ kind: 'database' }) const leasing = setup({ feeds: [feed('c1', { admitMode: 'db', admitModeEffective: 'reserve' }), feed('c2')] }) expect(leasing.directory.admitModeOf('c1')).toBe('reserve') + // A tripped cell still re-registering reports reserve as effective but db as raw: placement + // treats it as a database cell at once, books nothing there and paces nobody for it. + const reregistering = setup({ + feeds: [ + feed('c1', { admitModeEffective: 'reserve', admitModeRaw: 'db' }), + feed('c2', { admitModeEffective: 'reserve', admitModeRaw: 'db' }) + ] + }) + expect(await reregistering.assignment.plan(HOST, { reconnect: false, region: US })).toEqual({ kind: 'database' }) + expect(reregistering.reserved).toEqual([]) + expect(reregistering.directory.admitModeOf('c1')).toBe('reserve') }) it('before every live cell has answered, holds back only hosts that may be on an unheard cell', async () => { @@ -162,6 +181,21 @@ describe('reserve assignment on a director', () => { expect(plan).toMatchObject({ kind: 'answer', lane: 'sticky', assignment: { cellId: 'c1', assignmentEpoch: 7 } }) }) + it('after the reconcile demotes a seat behind its row, mints above the row and never answers that seat', async () => { + const { assignment, directory, reserved, now } = setup({ feeds: [feed('c1', { full: [seated(7)] }), feed('c2')] }) + // The row names another cell at epoch 8; the mirror would refuse a re-assign at 8. + assignment.raiseEpochFloor(HOST, 8) + const plan = await assignment.plan(HOST, { reconnect: true, region: US }) + expect(plan).toMatchObject({ kind: 'answer', lane: 'placement', assignment: { assignmentEpoch: 9 } }) + expect(reserved.every((call) => !call.sticky && call.epoch === 9)).toBe(true) + // The floor lapses, so a host it no longer protects answers from memory again. + now.value += RESERVE_ROW_FLOOR_TTL_MS + 1 + for (const response of [feed('c1', { seq: 2, full: [seated(7)] }), feed('c2', { seq: 2 })]) { + directory.apply(response.cellId, response, now.value, now.value) + } + expect(await assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ lane: 'sticky' }) + }) + it('never sends a drained or demoted host back, and books it elsewhere above every known epoch', async () => { const { assignment, directory, reserved, now } = setup() directory.apply( @@ -231,6 +265,62 @@ describe('reserve assignment on a director', () => { }) }) + it('after a WRONG_CELL leave, any director reads the row and mints above it', async () => { + const leftWrongCell = ( + row: () => Promise<{ cellId: string; assignmentEpoch: number } | null>, + databaseBusy?: () => boolean + ) => { + const harness = setup({ row, ...(databaseBusy ? { databaseBusy } : {}) }) + // Another director demoted the seat: this one only sees the leave in c1's feed. + harness.directory.apply( + 'c1', + feed('c1', { + full: undefined, + seq: 3, + changes: [ + { seq: 2, kind: 'join', ...HOST, epoch: 7, generation: 1, at: 1 }, + { seq: 3, kind: 'leave', ...HOST, epoch: 7, generation: 1, closeCode: 4409, at: 2 } + ] + }), + harness.now.value, + harness.now.value + ) + return harness + } + const row = vi.fn(async () => ({ cellId: 'c2', assignmentEpoch: 8 })) + const read = leftWrongCell(row) + expect(await read.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ + kind: 'answer', + lane: 'placement', + assignment: { assignmentEpoch: 9 } + }) + expect(row).toHaveBeenCalledTimes(1) + // The database down: placed as before the read existed, above what the map knows. + const down = leftWrongCell(async () => Promise.reject(new Error('timeout'))) + expect(await down.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ + kind: 'answer', + assignment: { assignmentEpoch: 8 } + }) + // Pool waiters: the read is skipped outright, so a stall cannot stack timed-out reads. + const busyRow = vi.fn(async () => ({ cellId: 'c2', assignmentEpoch: 8 })) + const busy = leftWrongCell(busyRow, () => true) + expect(await busy.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ + kind: 'answer', + assignment: { assignmentEpoch: 8 } + }) + expect(busyRow).not.toHaveBeenCalled() + // A stalled database: the read gives up after its bound and places as before. + vi.useFakeTimers() + try { + const stalled = leftWrongCell(() => new Promise(() => undefined)) + const plan = stalled.assignment.plan(HOST, { reconnect: true, region: US }) + await vi.advanceTimersByTimeAsync(RESERVE_WRONG_CELL_ROW_READ_MS) + expect(await plan).toMatchObject({ kind: 'answer', assignment: { assignmentEpoch: 8 } }) + } finally { + vi.useRealTimers() + } + }) + it('paces with Retry-After when every reserve cell is out of budget and no database cell exists', async () => { const { assignment } = setup({ reserve: () => ({ outcome: 'intake' }) }) await expect(assignment.plan(HOST, { reconnect: false, region: US })).rejects.toBeInstanceOf( diff --git a/cloud/apps/relay/src/reserve-assignment.ts b/cloud/apps/relay/src/reserve-assignment.ts index 701bdc1e0d40..47fe8d668830 100644 --- a/cloud/apps/relay/src/reserve-assignment.ts +++ b/cloud/apps/relay/src/reserve-assignment.ts @@ -49,9 +49,32 @@ export type ReservePlan = placeFresh?: () => Promise } +// A host whose seat the ledger reconcile demoted mints above its row for this long, so the +// re-assign does not collide with the row again (the mirror refuses an equal epoch). +export const RESERVE_ROW_FLOOR_TTL_MS = 2 * 60 * 60_000 +const RESERVE_ROW_FLOOR_MAX = 100_000 + +// The WRONG_CELL row read's bound: well under a database stall (5-7 s). +export const RESERVE_WRONG_CELL_ROW_READ_MS = 1_000 + +// Rejects after `ms`; the read itself still settles later, and its failure is swallowed. +async function withinMs(read: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('row_read_timeout')), ms) + }) + read.catch(() => undefined) + try { + return await Promise.race([read, timeout]) + } finally { + clearTimeout(timer) + } +} + export class ReserveAssignment { private summary: PlacementSummary = emptySummary() private summaryAt: number + private readonly rowFloors = new Map() constructor( private readonly input: { @@ -63,6 +86,9 @@ export class ReserveAssignment { client: CellReserveClient // One primary-key read of the host's row: only for a host the map has never seen. readRow: (identity: Identity) => Promise<{ cellId: string; assignmentEpoch: number } | null> + // True while the pool has waiters: the optional WRONG_CELL read is skipped, so timed-out + // reads cannot stack up behind a stall. + databaseBusy?: () => boolean now?: () => number random?: () => number log?: (line: string) => void @@ -89,7 +115,7 @@ export class ReserveAssignment { return this.input.cells().flatMap((cell) => { const state = directory.cellState(cell.cellId) if (!state) return [] - const reserve = directory.admitModeOf(cell.cellId) === 'reserve' + const reserve = directory.placementModeOf(cell.cellId) === 'reserve' return [ { cellId: cell.cellId, @@ -110,7 +136,7 @@ export class ReserveAssignment { } private hasReserveCells(): boolean { - return this.input.directory.cellIds().some((cellId) => this.input.directory.admitModeOf(cellId) === 'reserve') + return this.input.directory.cellIds().some((cellId) => this.input.directory.placementModeOf(cellId) === 'reserve') } async plan(identity: Identity, request: { reconnect: boolean; region: RelayRegion }): Promise { @@ -130,19 +156,34 @@ export class ReserveAssignment { .filter((entry) => now - entry.at <= RESERVE_STICKY_RECENT_MS) .at(-1) const booking = directory.bookingOf(identity.userId, identity.relayHostId) + const rowFloor = this.rowFloor(identity, now) const known = [ ...seats.map((seat) => seat.epoch), ...(left ? [left.epoch] : []), - ...(booking ? [booking.epoch] : []) + ...(booking ? [booking.epoch] : []), + ...(rowFloor === undefined ? [] : [rowFloor]) ] let row: { cellId: string; assignmentEpoch: number } | null = null - if (known.length === 0) { + // A seat closed with WRONG_CELL was demoted (or failed to re-register) because its row is + // ahead, which every director sees in that cell's feed: read the row, so the re-assign mints + // above it on any director, not just the one that demoted it. + const leftWrongCell = + left?.closeCode === RELAY_CLOSE_CODE.WRONG_CELL && !(this.input.databaseBusy?.() ?? false) + if (known.length === 0 || leftWrongCell) { try { - row = await this.input.readRow(identity) + // Only the never-seen host needs the row; for the WRONG_CELL one it is an optimisation, + // bounded so a database stall cannot hold the reconnect (the reconcile repairs a miss). + row = + known.length === 0 + ? await this.input.readRow(identity) + : await withinMs(this.input.readRow(identity), RESERVE_WRONG_CELL_ROW_READ_MS) } catch { // A host the map never saw, with the database down: its epoch cannot be minted safely. - this.summary.retries.database += 1 - return { kind: 'retry', retryAfterSeconds: 2, reason: 'database' } + // One the map knows places as it did before this read existed. + if (known.length === 0) { + this.summary.retries.database += 1 + return { kind: 'retry', retryAfterSeconds: 2, reason: 'database' } + } } if (row) known.push(row.assignmentEpoch) } @@ -158,7 +199,7 @@ export class ReserveAssignment { } if (request.reconnect) { - const sticky = await this.stickyFromMemory(identity, seats, left, now) + const sticky = await this.stickyFromMemory(identity, seats, left, now, rowFloor ?? 0) if (sticky) { this.summary.sticky += 1 return { kind: 'answer', assignment: sticky, lane: 'sticky' } @@ -168,7 +209,7 @@ export class ReserveAssignment { await this.placeFresh(identity, request.region, epochFloor, seats) // Where the host was last seen decides who owns it: a database-mode cell keeps today's // path, sticky included; only a fresh placement may be booked on a reserve-mode cell. - const lastOnReserve = lastCellId !== undefined && directory.admitModeOf(lastCellId) === 'reserve' + const lastOnReserve = lastCellId !== undefined && directory.placementModeOf(lastCellId) === 'reserve' if (!lastOnReserve && (seats.length > 0 || left !== undefined || row !== null)) { return { kind: 'database', epochFloor, placeFresh } } @@ -183,11 +224,14 @@ export class ReserveAssignment { identity: Identity, seats: ShadowSeat[], left: { cellId: string; epoch: number; incarnation?: string; closeCode?: number } | undefined, - now: number + now: number, + rowFloor: number ): Promise { // Highest epoch first, then the newest join: only the newest grant may be answered from // memory, so a stale seat (or a leaver at a lower epoch) is never sent back. const newest = Math.max(0, ...seats.map((seat) => seat.epoch), left?.epoch ?? 0) + // A seat at or behind a row the reconcile demoted it for is never answered again. + if (newest <= rowFloor) return null const candidates: Array<{ cellId: string epoch: number @@ -310,6 +354,42 @@ export class ReserveAssignment { return null } + // The ledger reconcile demoted this host's seat behind (or level with) a row at `epoch`. + raiseEpochFloor(identity: Identity, epoch: number): void { + const key = `${identity.userId}\u0000${identity.relayHostId}` + const current = this.rowFloors.get(key) + this.rowFloors.delete(key) + this.rowFloors.set(key, { + epoch: Math.max(epoch, current?.epoch ?? 0), + expiresAt: this.now() + RESERVE_ROW_FLOOR_TTL_MS + }) + if (this.rowFloors.size > RESERVE_ROW_FLOOR_MAX) { + this.rowFloors.delete(this.rowFloors.keys().next().value!) + } + } + + private rowFloor(identity: Identity, now: number): number | undefined { + const key = `${identity.userId}\u0000${identity.relayHostId}` + const floor = this.rowFloors.get(key) + if (!floor) return undefined + if (floor.expiresAt > now) return floor.epoch + this.rowFloors.delete(key) + return undefined + } + + // Fire-and-forget; the cell checks the seat's epoch and join itself. + demoteSeat(seat: { userId: string; relayHostId: string; cellId: string; epoch: number; joinedAt: number }): void { + const cell = this.input.cells().find((entry) => entry.cellId === seat.cellId) + if (!cell) return + void this.input.client.demote(cell, { + v: 1, + userId: seat.userId, + relayHostId: seat.relayHostId, + epoch: seat.epoch, + joinedAt: seat.joinedAt + }) + } + // Supersede at placement: the old seats this director's map holds on reserve cells are told // to go. Each cell checks the seat's epoch and join itself, so a stale map only misses. private supersede(identity: Identity, seats: readonly ShadowSeat[], cellId: string, epoch: number): void { diff --git a/cloud/apps/relay/src/reserve-ledger-writer-postgres.test.ts b/cloud/apps/relay/src/reserve-ledger-writer-postgres.test.ts new file mode 100644 index 000000000000..2276ed767d9d --- /dev/null +++ b/cloud/apps/relay/src/reserve-ledger-writer-postgres.test.ts @@ -0,0 +1,61 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openRelayDatabase, type RelayDatabase } from './database.js' +import { reconcileReserveLedger, ReserveLedgerWriter } from './reserve-ledger-writer.js' + +// The ledger's statements on real PostgreSQL: the guarded upsert, its counter reset on a +// move, and the batched row read. + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip +const USER = `ledger-pg-${randomUUID().slice(0, 8)}` + +describePostgres('reserve ledger writer on PostgreSQL', () => { + let database: RelayDatabase + + beforeAll(async () => { + database = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + }) + + afterAll(async () => { + await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [USER]) + await database.close() + }) + + const row = async (relayHostId: string) => + ( + await database.query( + `SELECT cell_id, assignment_epoch, reserved_controls FROM relay_assignments + WHERE user_id = ? AND relay_host_id = ?`, + [USER, relayHostId] + ) + )[0] + + it('writes only a higher epoch, drops a moved row’s counters, and reconciles from the map', async () => { + const writer = new ReserveLedgerWriter(database, { log: () => undefined }) + const host = 'ledgerpghost0001' + writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c1', epoch: 5 }) + await writer.flush() + expect(await row(host)).toMatchObject({ cell_id: 'c1', assignment_epoch: '5' }) + await database.query(`UPDATE relay_assignments SET reserved_controls = 2 WHERE user_id = ?`, [USER]) + writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c2', epoch: 5 }) + await writer.flush() + expect(await row(host)).toMatchObject({ cell_id: 'c1', assignment_epoch: '5', reserved_controls: '2' }) + writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c2', epoch: 6 }) + await writer.flush() + expect(await row(host)).toMatchObject({ cell_id: 'c2', assignment_epoch: '6', reserved_controls: '0' }) + const other = 'ledgerpghost0002' + const result = await reconcileReserveLedger({ + writer, + seats: () => [ + { userId: USER, relayHostId: host, cellId: 'c1', epoch: 4, joinedAt: 0 }, + { userId: USER, relayHostId: other, cellId: 'c1', epoch: 2, joinedAt: 0 } + ], + now: 1_000_000 + }) + await writer.flush() + expect(result).toEqual({ upserted: 1, demoted: 0 }) + expect(await row(host)).toMatchObject({ cell_id: 'c2', assignment_epoch: '6' }) + expect(await row(other)).toMatchObject({ cell_id: 'c1', assignment_epoch: '2' }) + }) +}) diff --git a/cloud/apps/relay/src/reserve-ledger-writer.test.ts b/cloud/apps/relay/src/reserve-ledger-writer.test.ts new file mode 100644 index 000000000000..ca51c6709c78 --- /dev/null +++ b/cloud/apps/relay/src/reserve-ledger-writer.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' +import { reconcileReserveLedger, ReserveLedgerWriter } from './reserve-ledger-writer.js' + +const HOST = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + +describe('reserve ledger writer', () => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + async function setup(now = () => 1_000) { + database = await openInMemoryRelayDatabase() + return { database, writer: new ReserveLedgerWriter(database, { now, log: () => undefined }) } + } + + const row = async (db: RelayDatabase, relayHostId = HOST.relayHostId) => + ( + await db.query( + `SELECT cell_id, assignment_epoch, reserved_controls FROM relay_assignments + WHERE user_id = ? AND relay_host_id = ?`, + [HOST.userId, relayHostId] + ) + )[0] + + it('writes a booked join, and only a higher epoch replaces it', async () => { + const { database: db, writer } = await setup() + writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) + writer.enqueue({ ...HOST, cellId: 'c2', epoch: 4 }) + writer.enqueue({ ...HOST, cellId: 'c3', epoch: 5 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) + writer.enqueue({ ...HOST, cellId: 'c2', epoch: 6 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c2', assignment_epoch: 6 }) + }) + + it('drops a moved row’s counters but keeps a same-cell row’s', async () => { + const { database: db, writer } = await setup() + await db.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES (?, ?, 'c1', 3, 0, 0, 2, 0, 0, 0, 0, 0), (?, ?, 'c1', 3, 0, 0, 2, 0, 0, 0, 0, 0)`, + [HOST.userId, HOST.relayHostId, HOST.userId, 'bbbbbbbbbbbbbbbb'] + ) + writer.enqueue({ ...HOST, cellId: 'c9', epoch: 4 }) + writer.enqueue({ userId: HOST.userId, relayHostId: 'bbbbbbbbbbbbbbbb', cellId: 'c1', epoch: 4 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c9', reserved_controls: 0 }) + expect(await row(db, 'bbbbbbbbbbbbbbbb')).toMatchObject({ cell_id: 'c1', reserved_controls: 2 }) + }) + + it('keeps its queue through a database stall and drops the oldest past the bound', async () => { + let now = 1_000 + const { database: db, writer } = await setup(() => now) + const query = vi.spyOn(db, 'query').mockRejectedValueOnce(new Error('timeout')) + writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 }) + await writer.flush() + expect(writer.pending()).toBe(1) + // Backoff: no retry before it is due. + await writer.flush() + expect(query).toHaveBeenCalledTimes(1) + now += 1_000 + await writer.flush() + expect(writer.pending()).toBe(0) + expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) + + const bounded = new ReserveLedgerWriter(db, { queueMax: 2, log: () => undefined }) + for (const epoch of [1, 2, 3]) bounded.enqueue({ ...HOST, cellId: 'c1', epoch }) + expect(bounded.pending()).toBe(2) + }) + + it('reconciles the map’s seats and never lowers an epoch', async () => { + const { database: db, writer } = await setup() + // Another path wrote a higher epoch on c7: the mirror leaves it, whatever the map says. + writer.enqueue({ ...HOST, cellId: 'c7', epoch: 9 }) + await writer.flush() + writer.enqueue({ ...HOST, cellId: 'c1', epoch: 9 }) + await writer.flush() + const other = { userId: 'user-2', relayHostId: 'cccccccccccccccc' } + const demoted: unknown[] = [] + const result = await reconcileReserveLedger({ + writer, + seats: () => [ + { ...HOST, cellId: 'c1', epoch: 8, joinedAt: 0 }, + { ...other, cellId: 'c1', epoch: 2, joinedAt: 0 }, + // Too young: it may still be superseded. + { userId: 'user-3', relayHostId: 'dddddddddddddddd', cellId: 'c1', epoch: 2, joinedAt: 990_000 } + ], + demote: (seat) => demoted.push(seat), + now: 1_000_000 + }) + await writer.flush() + // The row is ahead of the c1 seat: the row stays, and that seat is told to go. + expect(result).toEqual({ upserted: 1, demoted: 1 }) + expect(demoted).toEqual([{ ...HOST, cellId: 'c1', epoch: 8, joinedAt: 0 }]) + expect(await row(db)).toMatchObject({ cell_id: 'c7', assignment_epoch: 9 }) + const written = await db.query(`SELECT cell_id FROM relay_assignments WHERE user_id = 'user-2'`) + expect(written).toEqual([{ cell_id: 'c1' }]) + }) + + it('demotes a seat level with its row on another cell, which the mirror would never replace', async () => { + const { database: db, writer } = await setup() + writer.enqueue({ ...HOST, cellId: 'c7', epoch: 9 }) + await writer.flush() + const demoted: unknown[] = [] + const result = await reconcileReserveLedger({ + writer, + seats: () => [ + { ...HOST, cellId: 'c1', epoch: 9, joinedAt: 0 }, + // Level with its own row: nothing to do. + { ...HOST, relayHostId: 'eeeeeeeeeeeeeeee', cellId: 'c7', epoch: 9, joinedAt: 0 } + ], + demote: (seat, found) => demoted.push({ seat: seat.cellId, row: found }), + now: 1_000_000 + }) + expect(result).toEqual({ upserted: 1, demoted: 1 }) + expect(demoted).toEqual([{ seat: 'c1', row: { cellId: 'c7', epoch: 9 } }]) + }) +}) diff --git a/cloud/apps/relay/src/reserve-ledger-writer.ts b/cloud/apps/relay/src/reserve-ledger-writer.ts new file mode 100644 index 000000000000..d10d5d954cf9 --- /dev/null +++ b/cloud/apps/relay/src/reserve-ledger-writer.ts @@ -0,0 +1,225 @@ +import type { RelayDatabase } from './database.js' + +// Step 5's after-the-fact record: a reserve-mode cell admits a booked host without reading +// Postgres, and the director that booked it writes the host's relay_assignments row about a +// second later. Phones still resolve through that row until step 6. +// +// A guarded mirror, which nothing reads for a desktop's correctness: only a higher epoch +// replaces a row, and nothing ever lowers one. No relay_cells row and no activity lease is +// touched. A row whose cell changes drops its counters: the leases that back them keep +// their own cell, and unbacked units stay counted on the old cell until its next reconcile, +// which only makes that cell look fuller. + +export const RESERVE_LEDGER_FLUSH_MS = 250 +export const RESERVE_LEDGER_QUEUE_MAX = 200_000 +const RESERVE_LEDGER_BATCH = 500 +const RETRY_BASE_MS = 250 +const RETRY_MAX_MS = 5_000 +const SUMMARY_INTERVAL_MS = 60_000 + +export type LedgerEntry = { + userId: string + relayHostId: string + cellId: string + epoch: number +} + +export class ReserveLedgerWriter { + private queue: LedgerEntry[] = [] + private flushing = false + private failures = 0 + private retryAt = 0 + private timer: ReturnType | null = null + private dropped = 0 + private written = 0 + private summaryAt: number + + constructor( + private readonly database: RelayDatabase, + private readonly input: { + now?: () => number + queueMax?: number + log?: (line: string) => void + } = {} + ) { + this.summaryAt = this.now() + } + + private now(): number { + return (this.input.now ?? Date.now)() + } + + start(): void { + this.timer ??= setInterval(() => void this.flush(), RESERVE_LEDGER_FLUSH_MS) + this.timer.unref?.() + } + + stop(): void { + if (this.timer) clearInterval(this.timer) + this.timer = null + } + + // During a database stall the queue grows; past its bound the oldest entries go first, + // since the 10-minute reconcile rewrites every seat anyway. + enqueue(entry: LedgerEntry): void { + this.queue.push(entry) + const max = this.input.queueMax ?? RESERVE_LEDGER_QUEUE_MAX + if (this.queue.length > max) { + const excess = this.queue.length - max + this.queue.splice(0, excess) + this.dropped += excess + } + } + + pending(): number { + return this.queue.length + } + + // Single-flight; resolves once the queued rows are written or the attempt failed. + async flush(): Promise { + if (this.flushing || this.queue.length === 0 || this.now() < this.retryAt) { + this.logSummary() + return + } + this.flushing = true + const batch = this.queue.slice(0, RESERVE_LEDGER_BATCH) + try { + await this.write(batch) + this.queue.splice(0, batch.length) + this.written += batch.length + this.failures = 0 + this.retryAt = 0 + } catch { + this.failures += 1 + this.retryAt = this.now() + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (this.failures - 1)) + } finally { + this.flushing = false + this.logSummary() + } + } + + private async write(batch: LedgerEntry[]): Promise { + // The newest entry per host wins inside one batch. + const latest = new Map() + for (const entry of batch) { + const key = `${entry.userId}\u0000${entry.relayHostId}` + const known = latest.get(key) + if (!known || entry.epoch > known.epoch) latest.set(key, entry) + } + await this.upsert([...latest.values()]) + } + + private async upsert(entries: LedgerEntry[]): Promise { + const now = this.now() + const rows = entries.map(() => '(?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0)').join(', ') + const zeroOnMove = (column: string) => + `${column} = CASE WHEN relay_assignments.cell_id = excluded.cell_id + THEN relay_assignments.${column} ELSE 0 END` + await this.database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES ${rows} + ON CONFLICT (user_id, relay_host_id) DO UPDATE SET + ${[ + 'reserved_controls', + 'reserved_splices', + 'reserved_invites', + 'pending_installs', + 'pending_confirmations', + 'migration_leases' + ] + .map(zeroOnMove) + .join(',\n ')}, + cell_id = excluded.cell_id, + assignment_epoch = excluded.assignment_epoch, + last_activity_at = excluded.last_activity_at + WHERE relay_assignments.assignment_epoch < excluded.assignment_epoch`, + entries.flatMap((entry) => [ + entry.userId, + entry.relayHostId, + entry.cellId, + entry.epoch, + now, + now + ]) + ) + } + + // Rows for these hosts, in one read per batch. + async readRows( + hosts: ReadonlyArray<{ userId: string; relayHostId: string }> + ): Promise> { + const rows = new Map() + for (let start = 0; start < hosts.length; start += RESERVE_LEDGER_BATCH) { + const chunk = hosts.slice(start, start + RESERVE_LEDGER_BATCH) + const found = await this.database.query( + `SELECT user_id, relay_host_id, cell_id, assignment_epoch FROM relay_assignments + WHERE ${chunk.map(() => '(user_id = ? AND relay_host_id = ?)').join(' OR ')}`, + chunk.flatMap((host) => [host.userId, host.relayHostId]) + ) + for (const row of found) { + rows.set(`${String(row.user_id)}\u0000${String(row.relay_host_id)}`, { + cellId: String(row.cell_id), + epoch: Number(row.assignment_epoch) + }) + } + } + return rows + } + + private logSummary(): void { + const now = this.now() + if (now - this.summaryAt < SUMMARY_INTERVAL_MS) return + this.summaryAt = now + ;(this.input.log ?? ((line: string) => console.log(line)))( + JSON.stringify({ + event: 'orca_relay_reserve_ledger_summary', + written: this.written, + pending: this.queue.length, + dropped: this.dropped, + failures: this.failures + }) + ) + this.written = 0 + this.dropped = 0 + } +} + +export const RESERVE_LEDGER_RECONCILE_MS = 10 * 60_000 +// A seat this young may still be superseded; the next reconcile takes it. +const RECONCILE_SETTLE_MS = 30_000 + +type ReconcileSeat = { userId: string; relayHostId: string; cellId: string; epoch: number; joinedAt: number } + +// Every director upserts the map's seats on reserve-mode cells: guarded and idempotent, so +// concurrent runs are harmless. A row ahead of the seat (today's path answered a newer epoch, +// and the desktop was then answered the old seat from memory) is never rewritten: that seat is +// demoted (epoch-checked, closes with 4409), so the desktop re-assigns to what the row names. +// The demoting director then mints above the row's epoch for that host (see raiseEpochFloor). +export async function reconcileReserveLedger(input: { + writer: ReserveLedgerWriter + seats: () => ReconcileSeat[] + demote?: (seat: ReconcileSeat, row: { cellId: string; epoch: number }) => void + now: number +}): Promise<{ upserted: number; demoted: number }> { + const seats = input.seats().filter((seat) => input.now - seat.joinedAt >= RECONCILE_SETTLE_MS) + const rows = await input.writer.readRows(seats) + let upserted = 0 + let demoted = 0 + for (const seat of seats) { + const row = rows.get(`${seat.userId}\u0000${seat.relayHostId}`) + // Level with the row on another cell is the same divergence: the mirror never replaces it. + const behindRow = row && (row.epoch > seat.epoch || (row.epoch === seat.epoch && row.cellId !== seat.cellId)) + if (row && behindRow && input.demote) { + input.demote(seat, row) + demoted += 1 + continue + } + if (row && row.epoch >= seat.epoch) continue + input.writer.enqueue(seat) + upserted += 1 + } + return { upserted, demoted } +} diff --git a/cloud/apps/relay/src/reserve-placement-simulation.test.ts b/cloud/apps/relay/src/reserve-placement-simulation.test.ts new file mode 100644 index 000000000000..692bf1dc6f0f --- /dev/null +++ b/cloud/apps/relay/src/reserve-placement-simulation.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest' +import { + runReservePlacementSimulation, + type SimulationConfig, + type SimulationReport +} from './reserve-placement-simulation.js' + +const SEEDS = [11, 23, 47] +const rtt = (region: string) => (region === 'asia-east2' ? 170 : 10) + +function fleet(): SimulationConfig['cells'] { + const cells: SimulationConfig['cells'] = [] + for (let index = 0; index < 24; index += 1) { + cells.push({ + cellId: `us${String(index).padStart(2, '0')}`, + region: 'us-central1', + hardCap: 600, + ceiling: 480, + intakePerSec: 5, + // A third of the fleet stays in database mode, one cell on an image without step 5. + mode: index % 3 === 0 ? 'db' : 'reserve', + old: index === 0, + preseated: 150 + }) + } + for (let index = 0; index < 6; index += 1) { + cells.push({ + cellId: `asia${index}`, + region: 'asia-east2', + hardCap: 600, + ceiling: 480, + intakePerSec: 2, + mode: index === 0 ? 'db' : 'reserve', + preseated: 200 + }) + } + return cells +} + +function expectInvariants(report: SimulationReport): void { + expect(report.violations).toEqual([]) +} + +describe('step 5 deterministic simulation', () => { + it.each(SEEDS)( + 'holds invariants 1-3 and 5-8 across mixed versions, lagging polls, restarts and database stalls (seed %i)', + async (seed) => { + const report = await runReservePlacementSimulation({ + seed, + durationMs: 25 * 60_000, + directors: 5, + // One director still on the database-only revision. + oldDirectors: 1, + cells: fleet(), + hosts: 6_600, + hostRegion: (index) => (index % 5 === 0 ? 'asia-east2' : 'us-central1'), + arrivalPerSec: 20, + meanSessionMs: 8 * 60_000, + reconnectDelayMs: [500, 5_000], + duplicateAssignShare: 0.05, + halfOpenShare: 0.05, + pollLagMs: (director) => [0, 200, 1_000, 4_000, 10_000][director]!, + rttMs: rtt, + cellRestarts: [{ at: 6 * 60_000, cellId: 'us04' }, { at: 14 * 60_000, cellId: 'asia2' }], + directorRestarts: [{ at: 8 * 60_000, director: 2 }, { at: 16 * 60_000, director: 4 }], + // Drained hosts land elsewhere while their old seat lingers: placement supersedes it. + drains: [{ at: 12 * 60_000, cellId: 'us05', paceMs: 60_000 }], + databaseStalls: Array.from({ length: 3 }, (_, index) => ({ + at: (index * 610 + 120) * 1_000, + durationMs: 7_000 + })) + }) + expectInvariants(report) + expect(report.reservePlacements).toBeGreaterThan(500) + expect(report.stickyAnswers).toBeGreaterThan(500) + expect(report.seatedAtEnd).toBeGreaterThan(4_000) + // The run reached the paths the invariants guard, so a pass is not vacuous. + expect(report.supersedes).toBeGreaterThan(0) + expect(report.rowAheadDemotions).toBeGreaterThan(0) + expect(report.directorRestarts).toBe(2) + expect(report.stickyReserves).toBeGreaterThan(0) + expect(report.databasePlacements).toBeGreaterThan(0) + }, + 120_000 + ) + + it.each(SEEDS)( + 'near full: extra reserve calls stay within 10% and exhausted requests within 1% (seed %i)', + async (seed) => { + const cells = Array.from({ length: 17 }, (_, index) => ({ + cellId: `us${index}`, + region: 'us-central1' as const, + hardCap: 700, + ceiling: 600, + intakePerSec: 50, + mode: 'reserve' as const, + preseated: 582 + })) + const report = await runReservePlacementSimulation({ + seed, + durationMs: 3 * 60_000, + directors: 5, + cells, + hosts: 17 * 582 + 300, + hostRegion: () => 'us-central1', + arrivalPerSec: 10, + meanSessionMs: 0, + reconnectDelayMs: [1_000, 3_000], + duplicateAssignShare: 0, + halfOpenShare: 0, + pollLagMs: () => 0, + rttMs: rtt + }) + expectInvariants(report) + expect(report.reservePlacements).toBeGreaterThan(250) + expect(report.reserveCalls / report.reservePlacements - 1).toBeLessThanOrEqual(0.1) + expect(report.exhausted / report.requests).toBeLessThanOrEqual(0.01) + for (const share of Object.values(report.maxOccupancyShare)) expect(share).toBeLessThanOrEqual(1) + }, + 120_000 + ) + + it.each(SEEDS.flatMap((seed) => [[1_200_000, seed], [300_000, seed]] as const))( + 'a drain onto an empty cell at the %i ms pace spreads within each cell intake budget (seed %i)', + async (paceMs, seed) => { + // The c27 shape: the drained cell's hosts move at the 1,200 s pace onto five cells + // with free seats 2,805 / 841 / 777 / 667 / 26. + const free = [2_805, 841, 777, 667, 26] + const cells: SimulationConfig['cells'] = free.map((seats, index) => ({ + cellId: `asia${index + 1}`, + region: 'asia-east2', + hardCap: 3_000, + ceiling: 2_830, + intakePerSec: 2, + mode: 'reserve', + preseated: 2_830 - seats + })) + cells.unshift({ + cellId: 'asia0', + region: 'asia-east2', + hardCap: 3_000, + ceiling: 2_830, + intakePerSec: 2, + mode: 'reserve', + preseated: 2_770 + }) + const preseated = cells.reduce((sum, cell) => sum + (cell.preseated ?? 0), 0) + const report = await runReservePlacementSimulation({ + seed, + durationMs: 30 * 60_000, + directors: 5, + cells, + hosts: preseated, + hostRegion: () => 'asia-east2', + arrivalPerSec: 1, + meanSessionMs: 0, + reconnectDelayMs: [1_000, 3_000], + duplicateAssignShare: 0.02, + halfOpenShare: 0, + pollLagMs: () => 0, + rttMs: rtt, + drains: [{ at: 10_000, cellId: 'asia0', paceMs }] + }) + expectInvariants(report) + // Below every cell's budget nothing waits; above it the cells' buckets pace the drain. + if (paceMs === 300_000) expect(report.intakeRefusals + report.paced).toBeGreaterThan(0) + const moved = Object.entries(report.arrivalsByCell) + const total = moved.reduce((sum, [, count]) => sum + count, 0) + expect(total).toBeGreaterThan(2_000) + // Spread: the emptiest cell takes a share, not the drain. + expect((report.arrivalsByCell.asia1 ?? 0) / total).toBeLessThan(0.6) + expect(moved.filter(([, count]) => count / total > 0.1).length).toBeGreaterThanOrEqual(3) + }, + 120_000 + ) + + // Each fault breaks one rule; the matching invariant must fire, or its check measures nothing. + const mixed = (faults: SimulationConfig['faults']): SimulationConfig => ({ + seed: 11, + durationMs: 8 * 60_000, + directors: 5, + cells: fleet(), + hosts: 6_600, + hostRegion: (index) => (index % 5 === 0 ? 'asia-east2' : 'us-central1'), + arrivalPerSec: 20, + meanSessionMs: 4 * 60_000, + reconnectDelayMs: [500, 5_000], + duplicateAssignShare: 0.1, + halfOpenShare: 0.1, + pollLagMs: (director) => [0, 200, 1_000, 2_000, 3_000][director]!, + rttMs: rtt, + faults + }) + const nearFull = (faults: SimulationConfig['faults']): SimulationConfig => ({ + ...mixed(faults), + cells: Array.from({ length: 4 }, (_, index) => ({ + cellId: `us${index}`, + region: 'us-central1' as const, + hardCap: 600, + ceiling: 600, + intakePerSec: 500, + mode: 'reserve' as const, + preseated: 590 + })), + hosts: 4 * 590 + 400, + hostRegion: () => 'us-central1', + arrivalPerSec: 400, + meanSessionMs: 0 + }) + const drain = (faults: SimulationConfig['faults']): SimulationConfig => ({ + ...mixed(faults), + cells: [0, 1, 2].map((index) => ({ + cellId: `asia${index}`, + region: 'asia-east2' as const, + hardCap: 3_000, + ceiling: 2_800, + intakePerSec: 2, + mode: 'reserve' as const, + preseated: index === 0 ? 1_000 : 100 + })), + hosts: 1_200, + hostRegion: () => 'asia-east2', + meanSessionMs: 0, + drains: [{ at: 10_000, cellId: 'asia0', paceMs: 60_000 }] + }) + // The database stalls, the queue grows, and the director restarts before it drains. + const drainedWithoutSupersede = (): SimulationConfig => ({ + ...mixed({ skipSupersede: true }), + drains: [{ at: 60_000, cellId: 'us05', paceMs: 30_000 }] + }) + // Long enough for two reconciles: a seat behind its row must not survive the second. + const rowAheadLeftAlone = (): SimulationConfig => ({ + ...mixed({ noRowAheadDemote: true }), + durationMs: 25 * 60_000, + oldDirectors: 1 + }) + // Demote, re-assign, converge: a placement that never mints above the row is demoted again + // and again behind the same row. + const rowAheadNeverRises = (): SimulationConfig => ({ + ...mixed({ mintAtFloor: true }), + durationMs: 25 * 60_000, + oldDirectors: 1 + }) + const restartedMidQueue = (): SimulationConfig => ({ + ...mixed({ noBootReconcile: true }), + durationMs: 2 * 60_000, + databaseStalls: [{ at: 50_000, durationMs: 20_000 }], + directorRestarts: [0, 1, 2, 3, 4].map((director) => ({ at: 65_000, director })) + }) + it.each([ + [1, nearFull({ bookPastCap: true })], + [2, drain({ intakeBurst: 200, directorShare: 0.05 })], + [3, drainedWithoutSupersede()], + [5, mixed({ unguardedLedger: true })], + // A director restart mid-queue loses its booked joins' rows until its boot reconcile. + [5, restartedMidQueue()], + [8, rowAheadLeftAlone()], + [8, rowAheadNeverRises()], + [7, mixed({ noEpochFloor: true })] + ] as const)('invariant %i fires when its rule is broken', async (invariant, config) => { + const report = await runReservePlacementSimulation(config) + expect(report.violations.map((violation) => violation.invariant)).toContain(invariant) + }, 120_000) + + it('repairs a seat behind its row within two demotions, and reports re-seats that miss', async () => { + const config = { ...mixed({}), durationMs: 25 * 60_000, oldDirectors: 1 } + const report = await runReservePlacementSimulation(config) + expect(report.rowAheadDemotions).toBeGreaterThan(0) + expect(report.violations.filter((violation) => violation.invariant === 8)).toEqual([]) + expect(report.rowAheadDemotionsPerHost.max).toBeLessThanOrEqual(2) + // A director whose map has not seen the leave yet may re-place level with the row; the + // WRONG_CELL row read keeps that a small share (the rest are repaired by one more reconcile). + expect(report.badFirstReseats / report.rowAheadDemotions).toBeLessThan(0.1) + const withoutRead = await runReservePlacementSimulation({ ...config, faults: { noWrongCellRowRead: true } }) + process.stderr.write( + `row_ahead_repair ${JSON.stringify({ + withRead: { demotions: report.rowAheadDemotions, badFirstReseats: report.badFirstReseats, perHost: report.rowAheadDemotionsPerHost }, + withoutRead: { demotions: withoutRead.rowAheadDemotions, badFirstReseats: withoutRead.badFirstReseats, perHost: withoutRead.rowAheadDemotionsPerHost } + })}\n` + ) + }, 240_000) + + // Every director misses its feed polls for 150 s: each reserve cell's dead-man trips (60-120 s) + // while Postgres still says reserve, and fresh placement must find the tripped cells after. + const fleetTrip = (faults: SimulationConfig['faults']): SimulationConfig => ({ + ...mixed(faults), + cells: Array.from({ length: 6 }, (_, index) => ({ + cellId: `us${index}`, + region: 'us-central1' as const, + hardCap: 600, + ceiling: 480, + intakePerSec: 5, + mode: 'reserve' as const, + preseated: 100 + })), + hosts: 1_200, + hostRegion: () => 'us-central1', + durationMs: 6 * 60_000, + pollOutages: [{ at: 60_000, durationMs: 150_000 }] + }) + + // Token minting, the cells' token check or a partition: no director's poll gets through again. + // The cells' own rows still say db once they trip, so today's path recovers without the feed; + // before the trips a fully reserve region has no fresh placement (60-120 s). + it('recovers fresh placement while feed polls fail and stay failed, once the cells trip (invariant 9)', async () => { + const stuck = (faults: SimulationConfig['faults']) => ({ + ...fleetTrip(faults), + pollOutages: [{ at: 60_000, durationMs: Number.POSITIVE_INFINITY }] + }) + const report = await runReservePlacementSimulation(stuck({})) + expect(report.deadManTrips).toBe(6) + expect(report.violations.filter((violation) => violation.invariant === 9)).toEqual([]) + expect(report.databasePlacementsWhilePollsFail).toBeGreaterThan(0) + // The first cell trips 60-120 s into the outage; its row is read within a few seconds. + expect(report.pollFailureGapMs).toBeGreaterThanOrEqual(60_000) + expect(report.pollFailureGapMs).toBeLessThanOrEqual(130_000) + const blocked = await runReservePlacementSimulation(stuck({ pgReserveBlocksDatabase: true })) + expect(blocked.violations.map((violation) => violation.invariant)).toContain(9) + }, 240_000) + + // A tripped cell reports reserve while it re-registers, but db as its raw mode at once. + it('books nothing on a tripped cell while it re-registers, by its raw mode', async () => { + const slow = (faults: SimulationConfig['faults']) => ({ ...fleetTrip(faults), reregistrationMs: 120_000 }) + const report = await runReservePlacementSimulation(slow({})) + expect(report.deadManTrips).toBe(6) + expect(report.reserveCallsOnTrippedCells).toBe(0) + const reported = await runReservePlacementSimulation(slow({ noRawFeedMode: true })) + expect(reported.reserveCallsOnTrippedCells).toBeGreaterThan(0) + }, 240_000) + + it('places fresh hosts on the tripped cells once a fleet-wide poll outage ends (invariant 9)', async () => { + const report = await runReservePlacementSimulation(fleetTrip({})) + expect(report.deadManTrips).toBe(6) + expect(report.violations.filter((violation) => violation.invariant === 9)).toEqual([]) + expect(report.databasePlacements).toBeGreaterThan(0) + const blocked = await runReservePlacementSimulation(fleetTrip({ pgReserveBlocksDatabase: true })) + expect(blocked.deadManTrips).toBe(6) + expect(blocked.violations.map((violation) => violation.invariant)).toContain(9) + }, 240_000) +}) diff --git a/cloud/apps/relay/src/reserve-placement-simulation.ts b/cloud/apps/relay/src/reserve-placement-simulation.ts new file mode 100644 index 000000000000..a7de6f08101b --- /dev/null +++ b/cloud/apps/relay/src/reserve-placement-simulation.ts @@ -0,0 +1,1254 @@ +import { createHash } from 'node:crypto' +import type { RelayRegion } from '@orca-cloud/relay-contract' +import { CellReserveBook } from './cell-reserve-book.js' +import { CELL_INTAKE_BURST, CELL_RESERVE_TTL_MS, type ReserveOutcome } from './cell-reserve-contract.js' +import { + mintEpoch, + RESERVE_CELL_FRESH_MS, + RESERVE_MAX_TRIES, + ReservePlacer, + type PlacementCell +} from './reserve-placement.js' + +// A seeded discrete-event model of step 5 that drives the real CellReserveBook and +// ReservePlacer. Everything else (sockets, the database, desktops) is modelled. The checks +// are the design's invariants 1-8; a violation is recorded, never thrown, so one run reports +// every class it hit. + +export function seededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let value = state + value = Math.imul(value ^ (value >>> 15), value | 1) + value ^= value + Math.imul(value ^ (value >>> 7), value | 61) + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +type Event = { at: number; seq: number; run: () => void } + +class Clock { + now = 0 + private seq = 0 + private readonly heap: Event[] = [] + + schedule(delayMs: number, run: () => void): void { + const event = { at: this.now + Math.max(0, Math.round(delayMs)), seq: this.seq++, run } + this.heap.push(event) + let index = this.heap.length - 1 + while (index > 0) { + const parent = (index - 1) >> 1 + if (!before(this.heap[index]!, this.heap[parent]!)) break + ;[this.heap[index], this.heap[parent]] = [this.heap[parent]!, this.heap[index]!] + index = parent + } + } + + async runUntil(until: number, afterEach: () => void): Promise { + while (this.heap.length > 0 && this.heap[0]!.at <= until) { + const event = this.pop() + this.now = event.at + event.run() + // Lets the promise continuations the event released settle before the next event. + for (let tick = 0; tick < 12; tick += 1) await Promise.resolve() + afterEach() + } + this.now = until + } + + private pop(): Event { + const top = this.heap[0]! + const last = this.heap.pop()! + if (this.heap.length > 0) { + this.heap[0] = last + let index = 0 + for (;;) { + const left = index * 2 + 1 + const right = left + 1 + let smallest = index + if (left < this.heap.length && before(this.heap[left]!, this.heap[smallest]!)) smallest = left + if (right < this.heap.length && before(this.heap[right]!, this.heap[smallest]!)) smallest = right + if (smallest === index) break + ;[this.heap[index], this.heap[smallest]] = [this.heap[smallest]!, this.heap[index]!] + index = smallest + } + } + return top + } +} + +function before(left: Event, right: Event): boolean { + return left.at < right.at || (left.at === right.at && left.seq < right.seq) +} + +// `leased`: admitted through the database check, so its control renews a lease there and is +// closed when the row moves on, as today. +type CellSeat = { epoch: number; joinedAt: number; reservedBy?: string; demoted: boolean; leased?: boolean } +type CellLogEntry = + | { kind: 'join'; host: string; epoch: number; joinedAt: number; reservedBy?: string } + | { kind: 'leave'; host: string; epoch: number; at: number; wrongCell: boolean } + | { kind: 'reserve'; host: string; epoch: number } + | { kind: 'demoted'; host: string; epoch: number } + +const RECENT_SEAT_MS = 10 * 60_000 +const DEMOTE_CLOSE_MS = 60_000 +const CONTROL_RENEWAL_MS = 25_000 +// As the director's: a seat this young may still be superseded. +const RECONCILE_SETTLE_MS = 30_000 +// A drained seat lingers this long (its splices finishing) unless superseded first. +const DRAIN_GRACE_MS = 30_000 +// A cell closes a control silent for 75 s, checked once per 25 s ping. +const CONTROL_SILENCE_CLOSE_MS = 100_000 +// Longer than the slowest duplicate: a half-open seat's silence close. +const QUIESCE_MS = 150_000 + +export type SimulationViolation = { invariant: number; at: number; detail: string } + +// Each fault breaks one rule on purpose, so a test can show the matching invariant fires. +export type SimulationFaults = { + bookPastCap?: boolean + intakeBurst?: number + // Each director assumes this share of every cell's intake, instead of a fifth. + directorShare?: number + // Placement does not supersede the old seat: a duplicate then outlives its bound. + skipSupersede?: boolean + // A restarted director forgets its ledger queue and never reconciles. + noBootReconcile?: boolean + // The reconcile leaves a seat whose row is ahead of it alone. + noRowAheadDemote?: boolean + // Directors do not read the row after a WRONG_CELL leave (only the demoting one has a floor). + noWrongCellRowRead?: boolean + // Reserve placement re-uses the floor epoch instead of minting above it. + mintAtFloor?: boolean + // Today's path skips every cell Postgres records as reserve, ignoring the cell's own row. + pgReserveBlocksDatabase?: boolean + // Directors place by the reported mode, which stays reserve while a tripped cell re-registers. + noRawFeedMode?: boolean + unguardedLedger?: boolean + noEpochFloor?: boolean +} + +// relay_cell_admit_effective: each cell writes its raw mode every 15 s (cell-admit-effective-writer.ts), +// and today's path takes a reserve cell whose current-process row says db within 45 s +// (assignment-store.ts CELL_ADMIT_EFFECTIVE_FRESH_MS). +const CELL_ADMIT_EFFECTIVE_REFRESH_MS = 15_000 +const CELL_ADMIT_EFFECTIVE_FRESH_MS = 45_000 +// A tripped cell reports reserve until every control it admitted from memory holds a lease. +const DEFAULT_REREGISTRATION_MS = 20_000 + +// cell-reserve-dead-man.ts's window: 60-120 s, fixed per cell. +function deadManWindowMs(cellId: string): number { + return 60_000 + (createHash('sha256').update(cellId).digest().readUInt32BE(0) % 60_001) +} + +class SimDatabase { + up = true + // relay_cell_admit_modes: what the flag workflow recorded. A trip does not change it. + readonly reserveCells = new Set() + readonly rows = new Map() + // relay_cell_admit_effective: written by each cell itself, never by a director. + readonly admitEffective = new Map() + private readonly counts = new Map() + + set(host: string, row: { cellId: string; epoch: number }): void { + const previous = this.rows.get(host) + if (previous) this.counts.set(previous.cellId, (this.counts.get(previous.cellId) ?? 1) - 1) + this.counts.set(row.cellId, (this.counts.get(row.cellId) ?? 0) + 1) + this.rows.set(host, row) + } + + constructor( + private readonly violate: (invariant: number, detail: string) => void, + private readonly faults: SimulationFaults + ) {} + + // The director's after-the-fact write, a guarded mirror: only a higher epoch replaces a row. + upsert(host: string, cellId: string, epoch: number): void { + const row = this.rows.get(host) + const guarded = !this.faults.unguardedLedger + if (guarded && row && row.epoch >= epoch) return + if (row && epoch < row.epoch) this.violate(5, `ledger epoch fell for ${host}`) + this.set(host, { cellId, epoch }) + } + + // Today's assign writes the row it answers with. + assign(host: string, cellId: string, epoch: number): void { + const row = this.rows.get(host) + if (row && epoch <= row.epoch) this.violate(5, `db mint not above row for ${host}`) + this.set(host, { cellId, epoch }) + } + + countOn(cellId: string): number { + return this.counts.get(cellId) ?? 0 + } +} + +class SimCell { + readonly seats = new Map() + readonly recent = new Map() + readonly log: CellLogEntry[] = [] + // Per incarnation: a restarted cell starts with a full bucket. + readonly okBookingTimes: number[][] = [[]] + incarnation = 1 + draining = false + mode: 'reserve' | 'db' + bookingUnits = 0 + // What relay_cells says to a director that predates step 5: frozen at the flip. + frozenDatabaseCount = 0 + // The dead-man (cell-reserve-dead-man.ts): a reserve cell no placing director has booked on or + // polled with reserver=1 for its window flips itself to db, latched. + lastContactAt = 0 + trippedAt: number | null = null + reregisteredAt = 0 + // Booted and no flag object read yet: its mode is a default, so it writes no admit row. + flagsUnread = false + book: CellReserveBook + + constructor( + readonly cellId: string, + readonly region: RelayRegion, + readonly hardCap: number, + readonly ceiling: number, + readonly intakePerSec: number, + mode: 'reserve' | 'db', + // An image that predates step 5 reports no ceiling and has no reserve route. + readonly old: boolean, + private readonly clock: Clock, + private readonly faults: SimulationFaults, + private readonly violate: (invariant: number, detail: string) => void + ) { + this.mode = mode + this.book = this.newBook() + } + + private newBook(): CellReserveBook { + const limit = () => (this.faults.bookPastCap ? Infinity : this.ceiling) + return new CellReserveBook( + { + canReserve: () => this.seats.size + this.bookingUnits + 1 <= limit(), + tryReserve: () => { + if (this.seats.size + this.bookingUnits + 1 > limit()) return null + this.bookingUnits += 1 + let released = false + return { + release: () => { + if (released) return + released = true + this.bookingUnits -= 1 + } + } + } + }, + () => this.intakePerSec, + () => this.clock.now, + this.faults.intakeBurst + ) + } + + occupancy(): number { + return this.seats.size + this.bookingUnits + } + + contact(): void { + this.lastContactAt = this.clock.now + } + + checkDeadMan(windowMs: number, reregistrationMs: number): void { + if (this.mode !== 'reserve' || this.old || this.clock.now - this.lastContactAt <= windowMs) return + this.mode = 'db' + this.trippedAt = this.clock.now + this.reregisteredAt = this.clock.now + reregistrationMs + } + + // What the feed's admitModeEffective reports: `mode` is the raw one (switch file and dead-man). + effectiveMode(): 'db' | 'reserve' { + return this.mode === 'reserve' || this.clock.now < this.reregisteredAt ? 'reserve' : 'db' + } + + reserve(directorId: string, host: string, epoch: number, sticky: boolean): ReserveOutcome { + if (this.mode === 'reserve') this.contact() + const outcome = this.book.reserve( + directorId, + { userId: host, relayHostId: 'abcdefghijklmnop', epoch, ttlMs: CELL_RESERVE_TTL_MS, sticky }, + { + mode: this.mode, + draining: this.draining, + seatedEpoch: (userId) => this.seats.get(userId)?.epoch + } + ) + if (outcome.outcome === 'ok') { + this.log.push({ kind: 'reserve', host, epoch }) + if (!sticky) this.okBookingTimes.at(-1)!.push(this.clock.now) + } + return outcome + } + + hello(host: string, epoch: number, database: SimDatabase): boolean { + if (this.draining) return false + if (this.mode === 'reserve') { + const booking = this.book.take(host, 'abcdefghijklmnop', epoch) + if (booking) return this.seat(host, epoch, booking.directorId) + const seated = this.seats.get(host) + const recent = this.recent.get(host) + if ( + (seated && seated.epoch === epoch && !seated.demoted) || + (recent && recent.epoch === epoch && this.clock.now - recent.at <= RECENT_SEAT_MS) + ) { + return this.seat(host, epoch) + } + } + if (!database.up) return false + const row = database.rows.get(host) + if (!row || row.cellId !== this.cellId || row.epoch !== epoch) return false + return this.seat(host, epoch, undefined, true) + } + + private seat(host: string, epoch: number, reservedBy?: string, leased = false): boolean { + // A rebind keeps its unit; a new seat needs one under the hard cap. + if (!this.seats.has(host) && this.occupancy() + 1 > this.hardCap) return false + this.seats.set(host, { epoch, joinedAt: this.clock.now, reservedBy, demoted: false, leased }) + this.recent.delete(host) + this.log.push({ kind: 'join', host, epoch, joinedAt: this.clock.now, reservedBy }) + return true + } + + leave(host: string): void { + const seat = this.seats.get(host) + if (!seat) return + this.seats.delete(host) + if (!seat.demoted) this.recent.set(host, { epoch: seat.epoch, at: this.clock.now }) + this.log.push({ kind: 'leave', host, epoch: seat.epoch, at: this.clock.now, wrongCell: seat.demoted }) + } + + // The cell's own check: only the seat named by epoch and join, and only in reserve mode. + demote(host: string, epoch: number, joinedAt: number, onClose: () => void): void { + if (this.mode !== 'reserve') return + const seat = this.seats.get(host) + if (!seat || seat.epoch !== epoch || seat.joinedAt !== joinedAt || seat.demoted) return + seat.demoted = true + this.log.push({ kind: 'demoted', host, epoch }) + this.clock.schedule(DEMOTE_CLOSE_MS, () => { + if (this.seats.get(host) === seat) { + this.leave(host) + onClose() + } + }) + } + + // Seats, bookings and memory are gone; the image boots in database mode until its first flag read. + restart(): string[] { + const hosts = [...this.seats.keys()] + for (const host of hosts) this.leave(host) + this.book.clear() + this.book = this.newBook() + this.recent.clear() + this.incarnation += 1 + this.okBookingTimes.push([]) + const mode = this.mode + this.mode = 'db' + this.flagsUnread = true + this.clock.schedule(5_000, () => { + this.mode = mode + this.flagsUnread = false + }) + return hosts + } +} + +type MapSeat = { epoch: number; joinedAt: number; reservedBy?: string; demoted: boolean } + +type DirectorCellView = { + incarnation: number | null + cursor: number + seats: Map + placement: PlacementCell +} + +type Answer = + | { kind: 'cell'; cellId: string; epoch: number; calls: number } + | { kind: 'retry'; afterMs: number; calls: number } + +export type SimulationConfig = { + seed: number + durationMs: number + directors: number + oldDirectors?: number + cells: Array<{ + cellId: string + region: RelayRegion + hardCap: number + ceiling: number + intakePerSec: number + mode: 'reserve' | 'db' + old?: boolean + preseated?: number + }> + hosts: number + hostRegion: (index: number) => RelayRegion + // Fresh connects arrive at this rate per second until every host has connected once. + arrivalPerSec: number + meanSessionMs: number + reconnectDelayMs: [number, number] + duplicateAssignShare: number + // Share of departures whose old control stays half-open on the cell while the desktop + // connects elsewhere: the real source of a host seated on two cells. + halfOpenShare: number + pollLagMs: (director: number) => number + rttMs: (region: RelayRegion) => number + faults?: SimulationFaults + cellRestarts?: Array<{ at: number; cellId: string }> + // Cold start budget: a restarted director's estimates start empty and refill at its share of + // each cell's intake, so it books nothing on a cell for 1 / (intakePerSec / directors) s and + // paces the rest (Retry-After >= 1 s); the startup gate holds unheard hosts up to 30 s. + directorRestarts?: Array<{ at: number; director: number }> + databaseStalls?: Array<{ at: number; durationMs: number }> + // Every director misses every feed poll for this long (a director-side outage). + pollOutages?: Array<{ at: number; durationMs: number }> + // How long a tripped cell keeps reporting reserve while it leases its memory-admitted controls. + reregistrationMs?: number + drains?: Array<{ at: number; cellId: string; paceMs: number }> +} + +export type SimulationReport = { + violations: SimulationViolation[] + requests: number + placements: number + reserveCalls: number + exhausted: number + paced: number + databasePlacements: number + reservePlacements: number + // Rows that named a placement the desktop never used, rewritten to its real seat. + supersedes: number + directorRestarts: number + rowAheadDemotions: number + // Row-ahead demotions per demoted host: a repair that converges needs one. + rowAheadDemotionsPerHost: { max: number; p99: number } + deadManTrips: number + // Bookings sent to a cell after its dead-man tripped (the cell answers `off`). + reserveCallsOnTrippedCells: number + databasePlacementsWhilePollsFail: number + // From the first poll outage's start to the first fresh database placement inside it. + pollFailureGapMs: number | null + // First seats after a row-ahead demotion that land behind (or level with) the row again. + badFirstReseats: number + intakeRefusals: number + stickyReserves: number + stickyAnswers: number + seatedAtEnd: number + maxOccupancyShare: Record + arrivalsByCell: Record +} + +class SimDirector { + placer!: ReservePlacer + views = new Map() + recentlyLeft = new Map< + string, + { cellId: string; epoch: number; at: number; incarnation: number; wrongCell?: boolean } + >() + reserveEpochs = new Map() + // Row epochs this director demoted a host's seat behind: its next mint for the host clears them. + rowFloors = new Map() + ledgerQueue: Array<{ host: string; cellId: string; epoch: number }> = [] + answeredCells = new Set() + // Set by a restart: the lost ledger queue is rebuilt by one reconcile once the map is complete. + bootReconcilePending = false + restarted = false + readonly id: string + + constructor( + readonly index: number, + readonly old: boolean, + private readonly random: () => number, + private readonly clock: Clock, + private readonly share?: number + ) { + this.id = `d${index}` + this.reset() + } + + reset(): void { + this.placer = new ReservePlacer(() => this.clock.now, this.random, this.share) + this.views = new Map() + this.recentlyLeft = new Map() + this.reserveEpochs = new Map() + this.rowFloors = new Map() + this.answeredCells = new Set() + this.ledgerQueue = [] + } + + complete(cells: readonly SimCell[]): boolean { + return cells.every((cell) => this.answeredCells.has(cell.cellId)) + } + + knownEpochs(host: string): number[] { + const epochs: number[] = [] + for (const view of this.views.values()) { + const seat = view.seats.get(host) + if (seat) epochs.push(seat.epoch) + } + const reserved = this.reserveEpochs.get(host) + if (reserved !== undefined) epochs.push(reserved) + const left = this.recentlyLeft.get(host) + if (left) epochs.push(left.epoch) + return epochs + } +} + +export async function runReservePlacementSimulation( + config: SimulationConfig +): Promise { + const random = seededRandom(config.seed) + const clock = new Clock() + const violations: SimulationViolation[] = [] + const violate = (invariant: number, detail: string): void => { + if (violations.length < 50) violations.push({ invariant, at: clock.now, detail }) + } + const faults = config.faults ?? {} + const database = new SimDatabase(violate, faults) + const cells = config.cells.map( + (spec) => + new SimCell( + spec.cellId, + spec.region, + spec.hardCap, + spec.ceiling, + spec.intakePerSec, + spec.mode, + spec.old ?? false, + clock, + faults, + violate + ) + ) + const cellById = new Map(cells.map((cell) => [cell.cellId, cell])) + for (const cell of cells) if (cell.mode === 'reserve' && !cell.old) database.reserveCells.add(cell.cellId) + const inPollOutage = (at: number): boolean => + (config.pollOutages ?? []).some((outage) => at >= outage.at && at < outage.at + outage.durationMs) + const directors = Array.from( + { length: config.directors }, + (_, index) => + new SimDirector( + index, + index < (config.oldDirectors ?? 0), + random, + clock, + config.faults?.directorShare + ) + ) + const report: SimulationReport = { + violations, + requests: 0, + placements: 0, + reserveCalls: 0, + exhausted: 0, + paced: 0, + databasePlacements: 0, + reservePlacements: 0, + supersedes: 0, + directorRestarts: 0, + rowAheadDemotions: 0, + rowAheadDemotionsPerHost: { max: 0, p99: 0 }, + deadManTrips: 0, + reserveCallsOnTrippedCells: 0, + databasePlacementsWhilePollsFail: 0, + pollFailureGapMs: null, + badFirstReseats: 0, + intakeRefusals: 0, + stickyReserves: 0, + stickyAnswers: 0, + seatedAtEnd: 0, + maxOccupancyShare: {}, + arrivalsByCell: {} + } + const between = ([low, high]: [number, number]) => low + random() * (high - low) + + // Desktops: the cell and epoch each one currently uses. + type Host = { id: string; region: RelayRegion; current: { cellId: string; epoch: number } | null } + const hosts: Host[] = Array.from({ length: config.hosts }, (_, index) => ({ + id: `h${index}`, + region: config.hostRegion(index), + current: null + })) + const hostById = new Map(hosts.map((host) => [host.id, host])) + + // Pre-seated hosts arrive with database rows, as hosts on a cell before its flip. + let nextHost = 0 + for (const cell of cells) { + const spec = config.cells.find((entry) => entry.cellId === cell.cellId)! + for (let index = 0; index < (spec.preseated ?? 0) && nextHost < hosts.length; index += 1) { + const host = hosts[nextHost++]! + database.set(host.id, { cellId: cell.cellId, epoch: 1 }) + cell.seats.set(host.id, { epoch: 1, joinedAt: 0, demoted: false }) + cell.log.push({ kind: 'join', host: host.id, epoch: 1, joinedAt: 0 }) + host.current = { cellId: cell.cellId, epoch: 1 } + scheduleLeave(host) + } + cell.frozenDatabaseCount = cell.seats.size + } + + function scheduleLeave(host: Host): void { + if (config.meanSessionMs <= 0) return + const sessionMs = -Math.log(1 - random()) * config.meanSessionMs + clock.schedule(sessionMs, () => { + const current = host.current + if (!current || quiescing) return + const cell = cellById.get(current.cellId)! + if (cell.seats.get(host.id)?.epoch !== current.epoch) return + // A half-open control: the desktop moves on, the cell still holds the seat until the + // control's silence timeout closes it. + if (random() >= config.halfOpenShare) cell.leave(host.id) + else { + const seat = cell.seats.get(host.id) + clock.schedule(CONTROL_SILENCE_CLOSE_MS, () => { + if (cell.seats.get(host.id) === seat) cell.leave(host.id) + }) + } + host.current = null + clock.schedule(between(config.reconnectDelayMs), () => connect(host, true)) + }) + } + + // --- director polling --- + function poll(director: SimDirector, cell: SimCell): void { + if (inPollOutage(clock.now)) return + // A placing director's poll carries reserver=1: it is the cell's dead-man contact. + if (!director.old && cell.mode === 'reserve') cell.contact() + const requestedAt = clock.now + const view = director.views.get(cell.cellId) + const full = !view || view.incarnation !== cell.incarnation + const seats = full ? new Map([...cell.seats].map(([host, seat]) => [host, { ...seat }])) : null + const changes = full ? [] : cell.log.slice(view.cursor) + const snapshot = { + incarnation: cell.incarnation, + cursor: cell.log.length, + seats: cell.seats.size, + bookings: cell.book.count(), + tokens: cell.book.intakeSnapshot().tokens, + mode: faults.noRawFeedMode ? cell.effectiveMode() : cell.mode, + draining: cell.draining + } + const delay = config.rttMs(cell.region) + config.pollLagMs(director.index) + clock.schedule(delay, () => { + let current = director.views.get(cell.cellId) + if (full) { + if (current) { + for (const [host, seat] of current.seats) { + if (!seats!.has(host)) { + director.recentlyLeft.set(host, { + cellId: cell.cellId, + epoch: seat.epoch, + at: clock.now, + incarnation: current.incarnation ?? 0 + }) + } + } + } + current = { + incarnation: snapshot.incarnation, + cursor: snapshot.cursor, + seats: seats!, + placement: placementCell(cell, snapshot, requestedAt) + } + director.views.set(cell.cellId, current) + } else if (current && current.incarnation === snapshot.incarnation) { + for (const change of changes) applyChange(director, cell, current, change) + current.cursor = snapshot.cursor + current.placement = placementCell(cell, snapshot, requestedAt) + } else { + // A restarted director's (or a stale) delta: the next poll asks for a full snapshot. + return + } + director.answeredCells.add(cell.cellId) + }) + } + + function placementCell( + cell: SimCell, + snapshot: { seats: number; bookings: number; tokens: number; mode: string; draining: boolean }, + polledAt: number + ): PlacementCell { + return { + cellId: cell.cellId, + region: cell.region, + reserve: !cell.old && snapshot.mode === 'reserve', + general: true, + draining: snapshot.draining, + polledAt, + seats: snapshot.seats, + bookings: snapshot.bookings, + ceiling: cell.old ? undefined : cell.ceiling, + intakePerSec: cell.intakePerSec, + intakeTokens: snapshot.tokens + } + } + + function applyChange( + director: SimDirector, + cell: SimCell, + view: DirectorCellView, + change: CellLogEntry + ): void { + if (change.kind === 'reserve') { + director.reserveEpochs.set( + change.host, + Math.max(director.reserveEpochs.get(change.host) ?? 0, change.epoch) + ) + } else if (change.kind === 'join') { + view.seats.set(change.host, { + epoch: change.epoch, + joinedAt: change.joinedAt, + reservedBy: change.reservedBy, + demoted: false + }) + director.recentlyLeft.delete(change.host) + // Exactly one director writes each booked join: the one that booked it. + if (change.reservedBy === director.id) { + director.ledgerQueue.push({ host: change.host, cellId: cell.cellId, epoch: change.epoch }) + } + } else if (change.kind === 'leave') { + const seat = view.seats.get(change.host) + if (seat && seat.epoch === change.epoch) { + view.seats.delete(change.host) + director.recentlyLeft.set(change.host, { + cellId: cell.cellId, + epoch: change.epoch, + at: change.at, + incarnation: view.incarnation ?? 0, + wrongCell: change.wrongCell + }) + } + } else { + const seat = view.seats.get(change.host) + if (seat && seat.epoch === change.epoch) seat.demoted = true + } + } + + // Supersede at placement: the director that books a host on `cellId` tells the old seats its + // map holds on reserve cells to go. Each cell checks epoch and join itself. + function supersede(director: SimDirector, host: string, cellId: string, epoch: number): void { + if (faults.skipSupersede) return + for (const [seatCellId, view] of director.views) { + const seat = view.seats.get(host) + if (!seat || seat.demoted || seatCellId === cellId || seat.epoch >= epoch) continue + if (!view.placement.reserve) continue + const loser = cellById.get(seatCellId)! + report.supersedes += 1 + clock.schedule(config.rttMs(loser.region), () => + loser.demote(host, seat.epoch, seat.joinedAt, () => onSeatClosed(host, seatCellId, seat.epoch)) + ) + } + } + + function onSeatClosed(hostId: string, cellId: string, epoch: number): void { + const host = hostById.get(hostId)! + if (host.current?.cellId === cellId && host.current.epoch === epoch) { + host.current = null + clock.schedule(between(config.reconnectDelayMs), () => connect(host, true)) + } + } + + function flushLedger(director: SimDirector): void { + if (!database.up || director.ledgerQueue.length === 0) return + for (const entry of director.ledgerQueue) { + database.upsert(entry.host, entry.cellId, entry.epoch) + } + director.ledgerQueue = [] + } + + // Invariant 8: a reserve seat behind its row (today's path wrote a newer epoch, and the + // desktop was answered the old seat from memory), or level with it on another cell, is gone + // by the next reconcile, and the director that demoted it re-places the host above the row. + const behindRow = new Map() + const rowAheadDemotionsByHost = new Map() + const demotedBehindRow = new Map() + // A host demoted behind its row: its next answer, from any director, is that row or above it. + const reassignAbove = new Map() + function reconcileLedger(director: SimDirector): void { + if (!database.up || director.old) return + for (const [cellId, view] of director.views) { + if (!view.placement.reserve) continue + for (const [host, seat] of view.seats) { + if (seat.demoted || clock.now - seat.joinedAt < RECONCILE_SETTLE_MS) continue + const row = database.rows.get(host) + const live = cellById.get(cellId)!.seats.get(host) + const diverged = row && (row.epoch > seat.epoch || (row.epoch === seat.epoch && row.cellId !== cellId)) + if (row && diverged && live && live.joinedAt === seat.joinedAt && !live.demoted) { + const key = `${director.id}\u0000${host}\u0000${cellId}\u0000${seat.joinedAt}` + if (behindRow.has(key)) violate(8, `${host}@${seat.epoch} on ${cellId} still behind row @${row.epoch}`) + behindRow.set(key, clock.now) + if (faults.noRowAheadDemote) continue + report.rowAheadDemotions += 1 + director.rowFloors.set(host, Math.max(row.epoch, director.rowFloors.get(host) ?? 0)) + rowAheadDemotionsByHost.set(host, (rowAheadDemotionsByHost.get(host) ?? 0) + 1) + // A director that had not seen the leave may re-place level with the row once; a later + // divergence (a newer row) may demote the host again. One row never takes a third. + const sameRow = `${host}\u0000${row.cellId}\u0000${row.epoch}` + const behindThisRow = (demotedBehindRow.get(sameRow) ?? 0) + 1 + demotedBehindRow.set(sameRow, behindThisRow) + if (behindThisRow > 2) violate(8, `${host} demoted ${behindThisRow} times behind its row ${row.cellId}@${row.epoch}`) + reassignAbove.set(host, { ...row }) + const cell = cellById.get(cellId)! + clock.schedule(config.rttMs(cell.region), () => + cell.demote(host, seat.epoch, seat.joinedAt, () => onSeatClosed(host, cellId, seat.epoch)) + ) + continue + } + database.upsert(host, cellId, seat.epoch) + } + } + } + + // --- assignment --- + function reserveCall( + director: SimDirector, + cell: SimCell, + host: string, + epoch: number, + sticky: boolean + ): Promise { + const rtt = config.rttMs(cell.region) + return new Promise((resolve) => { + clock.schedule(rtt / 2, () => { + const outcome = cell.old ? { outcome: 'off' as const } : cell.reserve(director.id, host, epoch, sticky) + if (outcome.outcome === 'intake') report.intakeRefusals += 1 + if (outcome.outcome === 'off' && cell.trippedAt !== null) report.reserveCallsOnTrippedCells += 1 + clock.schedule(rtt / 2, () => resolve(outcome)) + }) + }) + } + + function databaseAssign(director: SimDirector, host: Host, floor: number): Answer { + if (!database.up) return { kind: 'retry', afterMs: 2_000, calls: 0 } + const row = database.rows.get(host.id) + // Postgres still says reserve after a trip; the cell's own fresh row (db, current process) + // lets today's path back in, with or without any feed poll getting through. + const admitsDatabase = (cell: SimCell): boolean => { + const own = database.admitEffective.get(cell.cellId) + return ( + !faults.pgReserveBlocksDatabase && + own?.mode === 'db' && + own.incarnation === cell.incarnation && + clock.now - own.at <= CELL_ADMIT_EFFECTIVE_FRESH_MS + ) + } + const eligible = cells.filter((cell) => { + if (cell.draining) return false + if (!director.old && database.reserveCells.has(cell.cellId) && !admitsDatabase(cell)) return false + const count = cell.mode === 'reserve' ? cell.frozenDatabaseCount : database.countOn(cell.cellId) + return count < cell.ceiling + }) + const load = (cell: SimCell) => + (cell.mode === 'reserve' ? cell.frozenDatabaseCount : database.countOn(cell.cellId)) / cell.ceiling + const inRegion = eligible.filter((cell) => cell.region === host.region) + const pool = inRegion.length > 0 ? inRegion : eligible + if (pool.length === 0) { + // A cell that tripped 20 s ago (a 1 s write, a 5 s cached read, slack) must be takeable. + const stranded = cells.find( + (cell) => + !director.old && + !cell.old && + !cell.draining && + cell.trippedAt !== null && + cell.mode === 'db' && + clock.now - cell.trippedAt >= 20_000 && + database.countOn(cell.cellId) < cell.ceiling + ) + if (stranded) violate(9, `${host.id}: ${stranded.cellId} tripped but takes no fresh placement`) + return { kind: 'retry', afterMs: 5_000, calls: 0 } + } + const target = pool.reduce((best, cell) => (load(cell) < load(best) ? cell : best)) + const epoch = Math.max(row?.epoch ?? 0, faults.noEpochFloor ? 0 : floor) + 1 + if (!director.old && epoch <= floor) violate(7, `db mint ${epoch} not above map floor ${floor}`) + database.assign(host.id, target.cellId, epoch) + if (target.mode === 'reserve') target.frozenDatabaseCount += 1 + report.databasePlacements += 1 + const outage = (config.pollOutages ?? [])[0] + if (inPollOutage(clock.now)) { + report.databasePlacementsWhilePollsFail += 1 + if (outage && report.pollFailureGapMs === null) report.pollFailureGapMs = clock.now - outage.at + } + return { kind: 'cell', cellId: target.cellId, epoch, calls: 0 } + } + + async function assign(director: SimDirector, host: Host, reconnect: boolean): Promise { + report.requests += 1 + if (director.old) return databaseAssign(director, host, 0) + if (!director.complete(cells)) return { kind: 'retry', afterMs: 1_000, calls: 0 } + const now = clock.now + const rowFloor = director.rowFloors.get(host.id) + const known = [...director.knownEpochs(host.id), ...(rowFloor === undefined ? [] : [rowFloor])] + let floor: number + if (known.length > 0) { + floor = Math.max(...known) + // A WRONG_CELL leave, seen in the cell's feed by every director: read the row too. + if (director.recentlyLeft.get(host.id)?.wrongCell && !faults.noWrongCellRowRead && database.up) { + floor = Math.max(floor, database.rows.get(host.id)?.epoch ?? 0) + } + } else { + if (!database.up) return { kind: 'retry', afterMs: 2_000, calls: 0 } + floor = database.rows.get(host.id)?.epoch ?? 0 + } + if (reconnect) { + const found = stickyFromMemory(director, host, now) + // The seat the reconcile demoted (or one level with its row) is never answered again. + const sticky = found && rowFloor !== undefined && found.epoch <= rowFloor ? null : found + if (sticky) { + const cell = cellById.get(sticky.cellId)! + // The map's own view of the cell's incarnation: a restart not yet polled answers stale, + // and the hello then falls to the database check. + const view = director.views.get(sticky.cellId) + if (sticky.incarnation === view?.incarnation || cell.old) { + report.stickyAnswers += 1 + return { kind: 'cell', cellId: sticky.cellId, epoch: sticky.epoch, calls: 0 } + } + report.stickyReserves += 1 + const outcome = await reserveCall(director, cell, host.id, sticky.epoch, true) + if (outcome.outcome === 'ok') { + report.stickyAnswers += 1 + return { kind: 'cell', cellId: sticky.cellId, epoch: sticky.epoch, calls: 1 } + } + } + } + const placements = [...director.views.values()].map((view) => view.placement) + if (random() >= director.placer.reservePoolShare(placements, host.region)) { + return databaseAssign(director, host, floor) + } + const result = await director.placer.place({ + cells: placements, + region: host.region, + epoch: faults.mintAtFloor ? Math.max(1, floor) : mintEpoch([floor]), + reserve: (cellId, epoch) => reserveCall(director, cellById.get(cellId)!, host.id, epoch, false) + }) + report.reserveCalls += result.calls + if (result.calls > RESERVE_MAX_TRIES) violate(6, `${result.calls} reserve calls in one request`) + if (result.kind === 'placed') { + report.reservePlacements += 1 + supersede(director, host.id, result.cellId, result.epoch) + return { kind: 'cell', cellId: result.cellId, epoch: result.epoch, calls: result.calls } + } + if (result.calls === RESERVE_MAX_TRIES) report.exhausted += 1 + if (result.kind === 'pace') { + report.paced += 1 + return { kind: 'retry', afterMs: result.retryAfterSeconds * 1_000, calls: result.calls } + } + const fallback = databaseAssign(director, host, floor) + return fallback.kind === 'cell' ? { ...fallback, calls: result.calls } : fallback + } + + function stickyFromMemory(director: SimDirector, host: Host, now: number) { + // The newest grant only: highest epoch, then newest join. + const seats = [...director.views] + .map(([cellId, view]) => ({ cellId, view, seat: view.seats.get(host.id) })) + .filter((entry) => entry.seat && !entry.seat.demoted) + .sort((left, right) => right.seat!.epoch - left.seat!.epoch || right.seat!.joinedAt - left.seat!.joinedAt) + const top = seats[0] + if (top && stickyCell(director, top.view, now, true)) { + return { cellId: top.cellId, epoch: top.seat!.epoch, incarnation: top.view.incarnation ?? 0 } + } + if (top) return null + const left = director.recentlyLeft.get(host.id) + // A seat closed WRONG_CELL may not rejoin from memory. + if (left && !left.wrongCell && now - left.at <= RECENT_SEAT_MS) { + const view = director.views.get(left.cellId) + if (view && stickyCell(director, view, now, false)) { + return { cellId: left.cellId, epoch: left.epoch, incarnation: left.incarnation } + } + } + return null + } + + function stickyCell(director: SimDirector, view: DirectorCellView, now: number, seated: boolean): boolean { + const cell = view.placement + if (!cell.reserve || !cell.general || cell.draining || cell.polledAt === null) return false + if (now - cell.polledAt > RESERVE_CELL_FRESH_MS) return false + return seated || director.placer.hasSeat(cell) + } + + // --- desktops --- + let directorCursor = 0 + function pickDirector(): SimDirector { + directorCursor = (directorCursor + 1 + Math.floor(random() * directors.length)) % directors.length + return directors[directorCursor]! + } + + // After the run, desktops stop moving so the maps and the ledger can settle. + let quiescing = false + function connect(host: Host, reconnect: boolean): void { + if (host.current || quiescing) return + const first = pickDirector() + const duplicate = random() < config.duplicateAssignShare ? pickDirector() : null + let used = false + const onAnswer = (answer: Answer, primary: boolean): void => { + if (answer.kind === 'retry') { + if (primary && !used) { + used = true + clock.schedule(answer.afterMs + random() * 500, () => connect(host, reconnect)) + } + return + } + if (used) return + used = true + clock.schedule(50 + random() * 250, () => hello(host, answer.cellId, answer.epoch, reconnect)) + } + // A throw in the model is a broken run, recorded like any violated rule. + const broken = (error: unknown) => violate(0, `model threw: ${String(error)}`) + void assign(first, host, reconnect) + .then((answer) => onAnswer(answer, true)) + .catch(broken) + if (duplicate) { + clock.schedule(random() * 500, () => { + void assign(duplicate, host, reconnect) + .then((answer) => onAnswer(answer, false)) + .catch(broken) + }) + } + } + + function hello(host: Host, cellId: string, epoch: number, reconnect: boolean): void { + const cell = cellById.get(cellId)! + if (host.current) return + const admitted = cell.hello(host.id, epoch, database) + if (!admitted) { + clock.schedule(1_000 + random() * 2_000, () => connect(host, reconnect)) + return + } + report.placements += 1 + report.arrivalsByCell[cellId] = (report.arrivalsByCell[cellId] ?? 0) + 1 + // Reported, not a violation: the first seat after a demotion behind the row lands on that + // row or above it, unless the answering director's map had not yet seen the leave. + const above = reassignAbove.get(host.id) + if (above !== undefined) { + reassignAbove.delete(host.id) + if (epoch < above.epoch || (epoch === above.epoch && cellId !== above.cellId)) report.badFirstReseats += 1 + } + host.current = { cellId, epoch } + scheduleLeave(host) + } + + // --- schedule --- + for (const cell of cells) { + const windowMs = deadManWindowMs(cell.cellId) + const check = (): void => { + const wasReserve = cell.mode === 'reserve' + cell.checkDeadMan(windowMs, config.reregistrationMs ?? DEFAULT_REREGISTRATION_MS) + if (wasReserve && cell.mode === 'db') report.deadManTrips += 1 + // Best-effort through the cell's own pool: nothing to write while the database is down, + // and only while its switch says reserve (a db cell never writes one). + const own = database.admitEffective.get(cell.cellId) + if ( + !cell.old && + database.reserveCells.has(cell.cellId) && + !cell.flagsUnread && + database.up && + (!own || own.mode !== cell.mode || own.incarnation !== cell.incarnation || clock.now - own.at >= CELL_ADMIT_EFFECTIVE_REFRESH_MS) + ) { + database.admitEffective.set(cell.cellId, { mode: cell.mode, incarnation: cell.incarnation, at: clock.now }) + } + clock.schedule(1_000, check) + } + clock.schedule(1_000, check) + } + for (const director of directors) { + const tick = (): void => { + for (const cell of cells) poll(director, cell) + if (director.bootReconcilePending && director.complete(cells)) { + director.bootReconcilePending = false + reconcileLedger(director) + } + clock.schedule(1_000, tick) + } + clock.schedule(random() * 1_000, tick) + const ledger = (): void => { + flushLedger(director) + clock.schedule(250, ledger) + } + clock.schedule(250, ledger) + const reconcile = (): void => { + reconcileLedger(director) + clock.schedule(10 * 60_000 + random() * 60_000, reconcile) + } + // As on a real director: once at boot when the map is complete, then every 10 minutes. + director.bootReconcilePending = true + clock.schedule(10 * 60_000 + random() * 60_000, reconcile) + } + // A leased control's renewal fails once its row names another seat: the cell closes it. + for (const cell of cells) { + const renew = (): void => { + if (database.up) { + for (const [hostId, seat] of [...cell.seats]) { + const row = database.rows.get(hostId) + if (!seat.leased || !row || (row.cellId === cell.cellId && row.epoch === seat.epoch)) continue + cell.leave(hostId) + onSeatClosed(hostId, cell.cellId, seat.epoch) + } + } + clock.schedule(CONTROL_RENEWAL_MS, renew) + } + clock.schedule(random() * CONTROL_RENEWAL_MS, renew) + } + for (const cell of cells) { + const sweep = (): void => { + cell.book.sweep() + clock.schedule(1_000, sweep) + } + clock.schedule(1_000, sweep) + } + const fresh = hosts.slice(nextHost) + fresh.forEach((host, index) => { + clock.schedule((index / config.arrivalPerSec) * 1_000, () => connect(host, false)) + }) + for (const restart of config.cellRestarts ?? []) { + clock.schedule(restart.at, () => { + const cell = cellById.get(restart.cellId)! + for (const hostId of cell.restart()) { + const host = hostById.get(hostId)! + if (host.current?.cellId !== cell.cellId) continue + host.current = null + clock.schedule(random() * 10_000, () => connect(host, true)) + } + }) + } + for (const restart of config.directorRestarts ?? []) { + clock.schedule(restart.at, () => { + const director = directors[restart.director]! + director.reset() + director.restarted = true + director.bootReconcilePending = !faults.noBootReconcile + report.directorRestarts += 1 + }) + } + for (const stall of config.databaseStalls ?? []) { + clock.schedule(stall.at, () => { + database.up = false + clock.schedule(stall.durationMs, () => { + database.up = true + }) + }) + } + for (const drain of config.drains ?? []) { + clock.schedule(drain.at, () => { + const cell = cellById.get(drain.cellId)! + cell.draining = true + const seated = [...cell.seats.keys()] + seated.forEach((hostId, index) => { + clock.schedule((index / Math.max(1, seated.length - 1)) * drain.paceMs, () => { + const host = hostById.get(hostId)! + const seat = cell.seats.get(hostId) + if (!seat) return + // Drain-only: the seat keeps its splices for the grace while the desktop moves on. + clock.schedule(DRAIN_GRACE_MS, () => { + if (cell.seats.get(hostId) === seat) cell.leave(hostId) + }) + if (host.current?.cellId === cell.cellId) { + host.current = null + clock.schedule(1_000 + random() * 5_000, () => connect(host, true)) + } + }) + }) + }) + } + + // --- invariants checked after every event --- + const afterEach = (): void => { + for (const cell of cells) { + if (cell.occupancy() > cell.hardCap) { + violate(1, `${cell.cellId} at ${cell.occupancy()} over hard cap ${cell.hardCap}`) + } + const share = cell.occupancy() / cell.hardCap + report.maxOccupancyShare[cell.cellId] = Math.max(report.maxOccupancyShare[cell.cellId] ?? 0, share) + } + } + // A booking supersedes the old seat within a round trip; a duplicate nobody books past (a + // half-open seat while today's path places the host) lasts until that seat's silence close. + const supersedeBound = + Math.max(...directors.map((director) => config.pollLagMs(director.index))) + + 2 * Math.max(...cells.map((cell) => config.rttMs(cell.region))) + + 3_000 + const duplicateBound = CONTROL_SILENCE_CLOSE_MS + 2 * Math.max(...cells.map((cell) => config.rttMs(cell.region))) + 1_000 + // Invariant 3: a seat outranked by a newer one (a higher epoch elsewhere) is gone within the + // bound. Only seats on reserve cells are step 5's: between database-mode cells today's lease + // renewal closes the stale control, as it does now. + const outrankedSince = new Map() + const checkDuplicates = (): void => { + const seatsByHost = new Map>() + for (const cell of cells) { + for (const [host, seat] of cell.seats) { + if (seat.demoted) continue + const seats = seatsByHost.get(host) ?? [] + seats.push({ cell, seat }) + seatsByHost.set(host, seats) + } + } + const live = new Set() + for (const [host, seats] of seatsByHost) { + if (seats.length < 2) continue + const newest = Math.max(...seats.map((entry) => entry.seat.epoch)) + // A seat outranked by a booking is superseded within the booking director's poll lag and + // a round trip; anything else (today's path placed the host) waits for the silence close. + const booked = seats.some((entry) => entry.seat.epoch === newest && entry.seat.reservedBy !== undefined) + for (const { cell, seat } of seats) { + if (cell.mode !== 'reserve' || seat.epoch === newest) continue + // A leased seat goes by its renewal, as today. + const bound = booked && !seat.leased ? supersedeBound : duplicateBound + const key = `${host}\u0000${cell.cellId}\u0000${seat.joinedAt}` + live.add(key) + const since = outrankedSince.get(key) ?? clock.now + outrankedSince.set(key, since) + if (clock.now - since > bound) { + violate(3, `${host}@${seat.epoch} on ${cell.cellId} outranked for ${clock.now - since} ms`) + } + } + } + for (const key of [...outrankedSince.keys()]) if (!live.has(key)) outrankedSince.delete(key) + clock.schedule(1_000, checkDuplicates) + } + clock.schedule(1_000, checkDuplicates) + + await clock.runUntil(config.durationMs, afterEach) + quiescing = true + await clock.runUntil(config.durationMs + QUIESCE_MS, afterEach) + + // Invariant 2: no window holds more ok bookings than burst + rate x window. + for (const cell of cells) { + for (const times of cell.okBookingTimes) { + let lowest = Infinity + times.forEach((at, index) => { + lowest = Math.min(lowest, index - (cell.intakePerSec * at) / 1_000) + const excess = index - (cell.intakePerSec * at) / 1_000 - lowest + 1 + if (excess > CELL_INTAKE_BURST + 1e-6) { + violate(2, `${cell.cellId} booked ${excess.toFixed(2)} over its bucket`) + } + }) + } + } + // Invariant 5: the ledger is a guarded mirror. After one reconcile with the database up, a + // host seated once, at its highest epoch, on a reserve cell has a row at that epoch or above. + // No final reconcile: what the queues, the boot reconcile and the 10-minute one wrote is + // what a reader would see. + database.up = true + for (const director of directors) flushLedger(director) + for (const cell of cells) { + if (cell.mode !== 'reserve') continue + for (const [host, seat] of cell.seats) { + if (seat.demoted) continue + const otherSeat = cells.some( + (other) => other !== cell && other.seats.get(host) && !other.seats.get(host)!.demoted + ) + if (otherSeat) continue + const row = database.rows.get(host) + if (!row || row.epoch < seat.epoch) { + violate(5, `${host} row ${JSON.stringify(row)} is below its seat ${cell.cellId}@${seat.epoch}`) + } + } + } + report.seatedAtEnd = cells.reduce((sum, cell) => sum + cell.seats.size, 0) + const perHost = [...rowAheadDemotionsByHost.values()].sort((left, right) => left - right) + report.rowAheadDemotionsPerHost = { + max: perHost.at(-1) ?? 0, + p99: perHost[Math.min(perHost.length - 1, Math.floor(perHost.length * 0.99))] ?? 0 + } + return report +} diff --git a/cloud/apps/relay/src/seat-feed-cells-postgres.test.ts b/cloud/apps/relay/src/seat-feed-cells-postgres.test.ts new file mode 100644 index 000000000000..be621f89bc81 --- /dev/null +++ b/cloud/apps/relay/src/seat-feed-cells-postgres.test.ts @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +// The seat-feed cell list (with the process incarnation the reserve switch keys on) on real PostgreSQL. + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +const run = randomUUID().slice(0, 8) +const cell: RelayCellConfig = { + id: `feed-pg-${run}`, + url: `https://feed-${run}.example.com`, + capacityRequests: 10, + connectionHardCap: 600, + connectionUnobservedBound: 50 +} +const INCARNATION = '22222222-2222-4222-8222-222222222222' + +describePostgres('seat-feed cells on PostgreSQL', () => { + let database: RelayDatabase + + beforeAll(async () => { + database = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + }) + + afterAll(async () => { + for (const table of ['relay_cell_admission', 'relay_cell_regions', 'relay_cell_runtime', 'relay_cells']) { + await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id]) + } + await database.close() + }) + + it('lists a heartbeating cell with its incarnation', async () => { + const store = new RelayAssignmentStore(database, () => 1_000, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) + await store.reconcileCells([cell], false) + expect((await store.seatFeedCells()).find((entry) => entry.cellId === cell.id)).not.toHaveProperty( + 'incarnation' + ) + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: INCARNATION, + startedAt: 50, + ready: true, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + expect((await store.seatFeedCells()).find((entry) => entry.cellId === cell.id)).toMatchObject({ + cellUrl: cell.url, + requiredForComplete: true, + incarnation: INCARNATION + }) + }) +}) diff --git a/cloud/apps/relay/src/shadow-directory-compare.test.ts b/cloud/apps/relay/src/shadow-directory-compare.test.ts index cfa33e912957..896ad9297c4f 100644 --- a/cloud/apps/relay/src/shadow-directory-compare.test.ts +++ b/cloud/apps/relay/src/shadow-directory-compare.test.ts @@ -60,7 +60,7 @@ describe('classifyShadowSeat', () => { [directoryWith({ 'cell-a': [seat(3)] }), at('cell-a', 3), 'agree', true], [directoryWith({ 'cell-a': [] }), null, 'agree-absent', true], [directoryWith({ 'cell-a': [seat(3)] }), null, 'map-only', false], - [directoryWith({ 'cell-a': [] }, ['cell-b']), at('cell-b', 3), 'cell-unpolled', true], + [directoryWith({ 'cell-a': [] }, ['cell-b']), at('cell-b', 3), 'cell-no-feed', true], [directoryWith({ 'cell-a': [] }), at('cell-z', 3), 'cell-unpolled', true], [directoryWith({ 'cell-a': [] }), at('cell-a', 3), 'db-only-unseen', true], [ @@ -153,6 +153,18 @@ describe('classifyShadowSeat', () => { }) }) + it('keeps a cell without a feed out of the coverage count, on either side of the compare', () => { + // cell-b reported a seat, then rolled back to an image without the feed. + const directory = directoryWith({ 'cell-b': [seat(2)], 'cell-a': [] }) + directory.markNoFeed('cell-b', 20) + for (const answer of [at('cell-a', 3), at('cell-b', 2)]) { + expect(classifyShadowSeat(directory, IDENTITY, answer, 30)).toEqual({ + class: 'cell-no-feed', + explained: true + }) + } + }) + it('reads a null answer on a cell whose heartbeat ran out after the list read as pending', () => { const directory = directoryWith({ 'cell-a': [seat(3)] }) const listRead = (readAt: number) => diff --git a/cloud/apps/relay/src/shadow-directory-compare.ts b/cloud/apps/relay/src/shadow-directory-compare.ts index 5d0d7e378ab7..328b5fa3bb40 100644 --- a/cloud/apps/relay/src/shadow-directory-compare.ts +++ b/cloud/apps/relay/src/shadow-directory-compare.ts @@ -12,6 +12,8 @@ export type ShadowCompareClass = // Neither the database nor the map has the host. | 'agree-absent' | 'map-incomplete' + // The cell's image has no seat feed (c17/c18 by design): outside the map, so not a coverage gap. + | 'cell-no-feed' | 'cell-unpolled' | 'db-only-left' | 'db-only-unseen' @@ -58,7 +60,11 @@ export function classifyShadowSeat( if (liveness.includes('unknown')) return { class: 'cell-unlive-pending', explained: true } return { class: 'map-only', explained: false } } - if (directory.cellState(answer.cellId)?.status !== 'live') { + const answerStatus = directory.cellState(answer.cellId)?.status + if (answerStatus === 'no-feed') { + return { class: 'cell-no-feed', explained: true } + } + if (answerStatus !== 'live') { return { class: 'cell-unpolled', explained: true } } if (seats.length > 1) { @@ -77,6 +83,9 @@ export function classifyShadowSeat( const mapBehind = answer.assignmentEpoch > seat.epoch && now - movedAt <= SHADOW_COMPARE_LAG_BOUND_MS if (seat.cellId !== answer.cellId) { + if (directory.cellState(seat.cellId)?.status === 'no-feed') { + return { class: 'cell-no-feed', explained: true } + } // A stale seat cell is a coverage gap, kept visible rather than explained away. const seatPolledAt = directory.cellState(seat.cellId)?.lastLiveAt ?? 0 if (now - seatPolledAt > SHADOW_COMPARE_LAG_BOUND_MS) { diff --git a/cloud/apps/relay/src/shadow-seat-directory.test.ts b/cloud/apps/relay/src/shadow-seat-directory.test.ts index 83822afa8fd0..e57428a6b032 100644 --- a/cloud/apps/relay/src/shadow-seat-directory.test.ts +++ b/cloud/apps/relay/src/shadow-seat-directory.test.ts @@ -362,6 +362,45 @@ describe('startShadowSeatPoller', () => { expect(cell.directory.isComplete()).toBe(true) }) + it('forgets the switches a cell reported once its image no longer has the feed', async () => { + let rolledBack = false + const cell = poller({ + 'cell-a': () => json(feed({ seq: 1, full: [] })), + 'cell-b': () => + rolledBack + ? new Response('Not Found', { status: 404 }) + : json( + feed({ + cellId: 'cell-b', + seq: 1, + full: [], + flagsApplied: { generation: 3, flags: { readinessLocal: true } } + }) + ) + }) + await cell.tick() + expect(cell.directory.cellState('cell-b')?.flagsApplied?.generation).toBe(3) + rolledBack = true + await cell.tick() + cell.stop() + expect(cell.directory.cellState('cell-b')).toMatchObject({ status: 'no-feed' }) + expect(cell.directory.cellState('cell-b')?.flagsApplied).toBeUndefined() + }) + + it('keeps a reserve switch through a 404 until a feed says db or the process is replaced', () => { + const directory = new ShadowSeatDirectory() + directory.setCells(['cell-b']) + const reserve = { generation: 4, flags: { admitMode: 'reserve' } } + directory.apply('cell-b', feed({ cellId: 'cell-b', seq: 1, full: [], flagsApplied: reserve }), 10) + directory.markNoFeed('cell-b', 20, INCARNATION) + expect(directory.cellState('cell-b')?.flagsApplied).toEqual(reserve) + directory.markNoFeed('cell-b', 30) + expect(directory.cellState('cell-b')?.flagsApplied).toEqual(reserve) + // relay_cell_runtime names another process: the one that reported reserve is gone. + directory.markNoFeed('cell-b', 40, '33333333-3333-4333-8333-333333333333') + expect(directory.cellState('cell-b')?.flagsApplied).toBeUndefined() + }) + it('completes without a cell that is dead, empty or isolated', async () => { const cell = poller({ 'cell-a': () => json(feed({ seq: 1, full: [] })) }, 'all', async () => [ CELLS[0]!, @@ -489,3 +528,31 @@ describe('RelayAssignmentStore.seatFeedCells', () => { ]) }) }) + +describe('ShadowSeatDirectory.placementModeOf', () => { + const response = ( + modes: { admitModeEffective?: 'db' | 'reserve'; admitModeRaw?: 'db' | 'reserve' } = {} + ): SeatFeedResponse => ({ + v: 1, + cellId: 'cell-a', + incarnation: INCARNATION, + seq: 1, + at: 0, + counts: { seats: 0, controls: 0, bookings: 0, units: 0, ceiling: 500 }, + intake: { perSec: 100, burst: 20, tokens: 20 }, + flagsApplied: { generation: 3, flags: { admitMode: 'reserve' } }, + full: [], + ...modes + }) + + // A tripped cell re-registering still reports reserve as effective; placement reads raw db. + it('reads the raw mode when the cell sends it, and the effective mode otherwise', () => { + const directory = new ShadowSeatDirectory() + directory.setCells(['cell-a']) + directory.apply('cell-a', response({ admitModeEffective: 'reserve', admitModeRaw: 'db' }), 1_000, 1_000) + expect(directory.placementModeOf('cell-a')).toBe('db') + expect(directory.admitModeOf('cell-a')).toBe('reserve') + directory.apply('cell-a', response({ admitModeEffective: 'reserve' }), 2_000, 2_000) + expect(directory.placementModeOf('cell-a')).toBe('reserve') + }) +}) diff --git a/cloud/apps/relay/src/shadow-seat-directory.ts b/cloud/apps/relay/src/shadow-seat-directory.ts index 8cd0f7814dba..6a491123e718 100644 --- a/cloud/apps/relay/src/shadow-seat-directory.ts +++ b/cloud/apps/relay/src/shadow-seat-directory.ts @@ -78,6 +78,8 @@ const SeatFeedSchema = z.object({ // What the cell acts on: reserve also while a flip back is still leasing its controls, or // db once its dead-man has flipped it back. Absent from older images. admitModeEffective: z.enum(['db', 'reserve']).optional(), + // Switch file and dead-man only, without the flip back: db the moment the cell trips. + admitModeRaw: z.enum(['db', 'reserve']).optional(), changes: z.array(SeatChangeSchema).optional(), // The cell cut the page (2,000 changes); poll again at once. more: z.boolean().optional(), @@ -95,6 +97,8 @@ export type SeatFeedCell = { heartbeatExpiresAt: number | null // Live, with capacity, and not roll-isolated: completeness waits only on these. requiredForComplete: boolean + // relay_cell_runtime's: a new one means the process that reported its switches is gone. + incarnation?: string // Admission state general and not roll-isolated: step 5 places only on these. general?: boolean } @@ -137,7 +141,10 @@ export type SeatFeedCellState = { reportedSeats?: number reportedControls?: number flagsApplied?: SeatFeedResponse['flagsApplied'] + // The incarnation whose feed reported `flagsApplied`. + flagsIncarnation?: string admitModeEffective?: 'db' | 'reserve' + admitModeRaw?: 'db' | 'reserve' // Step 5: when the answered poll was sent, and the cell's own reserve numbers. polledAt?: number bookings?: number @@ -255,7 +262,9 @@ export class ShadowSeatDirectory { cursor.reportedSeats = response.counts?.seats cursor.reportedControls = response.counts?.controls cursor.flagsApplied = response.flagsApplied + cursor.flagsIncarnation = response.incarnation cursor.admitModeEffective = response.admitModeEffective + cursor.admitModeRaw = response.admitModeRaw cursor.polledAt = polledAt cursor.bookings = response.counts?.bookings cursor.units = response.counts?.units @@ -275,25 +284,52 @@ export class ShadowSeatDirectory { return cursor?.flagsApplied?.flags.admitMode === 'reserve' ? 'reserve' : 'db' } + // What placement treats the cell as: a tripped cell is a database cell at once, so nothing + // books into it or paces for it while it re-registers. Older images report no raw mode. + placementModeOf(cellId: string): 'db' | 'reserve' { + const raw = this.cells.get(cellId)?.admitModeRaw + return raw ?? this.admitModeOf(cellId) + } + cellIds(): string[] { return [...this.cells.keys()] } + // One cell's seats as the map holds them. + seatsOnCell(cellId: string): Array<{ userId: string; relayHostId: string; seat: ShadowSeat }> { + const cursor = this.cells.get(cellId) + if (!cursor) return [] + return [...cursor.seats].map(([key, seat]) => { + const separator = key.indexOf('\u0000') + return { userId: key.slice(0, separator), relayHostId: key.slice(separator + 1), seat } + }) + } // An old cell (404) has no feed; its last known seats stay, marked by status. - markNoFeed(cellId: string, now: number): void { + markNoFeed(cellId: string, now: number, runtimeIncarnation?: string): void { const cursor = this.cells.get(cellId) if (!cursor) return cursor.status = 'no-feed' cursor.lastAnsweredAt = now cursor.incarnation = undefined cursor.seq = undefined - // An image without the feed takes no booking. Its reserve switch is #27058's to keep. + // An image without the feed takes no booking. cursor.polledAt = undefined cursor.bookings = undefined cursor.units = undefined cursor.ceiling = undefined cursor.intake = undefined + // A reserve switch stands until a feed says db, or the cell's process is replaced: one 404 + // must not read a reserve cell's seated hosts as idle. Anything else is forgotten. + const reserve = cursor.flagsApplied?.flags.admitMode === 'reserve' + const replaced = + runtimeIncarnation !== undefined && runtimeIncarnation !== cursor.flagsIncarnation + if (!reserve || replaced) { + cursor.flagsApplied = undefined + cursor.flagsIncarnation = undefined + cursor.admitModeEffective = undefined + cursor.admitModeRaw = undefined + } } fail(cellId: string, reason: string): void { @@ -670,7 +706,7 @@ export function startShadowSeatPoller( if (stopped) return false if (response.status === 404) { await response.body?.cancel().catch(() => undefined) - directory.markNoFeed(cell.cellId, now()) + directory.markNoFeed(cell.cellId, now(), cell.incarnation) return false } if (!response.ok) { diff --git a/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts b/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts index aff1f7e1ea31..0242f499a631 100644 --- a/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts +++ b/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts @@ -48,6 +48,8 @@ export async function startReserveModeCell(input: { cleanup: Array<() => Promise | void> databasePoolMax?: number now?: () => number + // Booted, no flag object read yet (generation 0, defaults). + flagsUnread?: boolean }) { const keys = await generateKeyPair('ES256') const publicJwk = await exportJWK(keys.publicKey) @@ -90,10 +92,9 @@ export async function startReserveModeCell(input: { publicAssignmentRetryAfterSeconds: 5, dataDir: input.dataDir } satisfies RelayConfig - let applied: AppliedControlFlags = { - generation: 1, - flags: { ...CELL_FLAG_DEFAULTS, admitMode: 'reserve' } - } + let applied: AppliedControlFlags = input.flagsUnread + ? { generation: 0, flags: CELL_FLAG_DEFAULTS } + : { generation: 1, flags: { ...CELL_FLAG_DEFAULTS, admitMode: 'reserve' } } const relay = createRelayServer(config, input.database, { cellFlags: () => applied, ...(input.now ? { now: input.now } : {}) @@ -204,6 +205,14 @@ export async function startReserveModeCell(input: { WHERE activity_kind = 'control' ORDER BY relay_host_id` ) + const admitEffectiveRow = async () => + ( + await input.database.query( + `SELECT cell_incarnation, mode, updated_at FROM relay_cell_admit_effective WHERE cell_id = ?`, + [config.cellId] + ) + )[0] + return { relay, config, @@ -212,6 +221,7 @@ export async function startReserveModeCell(input: { insertRow, bookedHost, controlLeases, + admitEffectiveRow, setAdmitMode: (admitMode: 'db' | 'reserve', flags: Partial = {}) => { applied = { generation: applied.generation + 1, flags: { ...CELL_FLAG_DEFAULTS, ...flags, admitMode } } } diff --git a/cloud/dev/fixtures/terraform-root-partition/families.json b/cloud/dev/fixtures/terraform-root-partition/families.json index 200de64d77a1..55ef3f894adb 100644 --- a/cloud/dev/fixtures/terraform-root-partition/families.json +++ b/cloud/dev/fixtures/terraform-root-partition/families.json @@ -137,8 +137,13 @@ "google_logging_metric.relay_assignment_lease_shadow", "google_logging_metric.relay_cell_fix_level", "google_logging_metric.relay_cell_pool_herd", + "google_logging_metric.relay_cell_reregistration_gave_up", + "google_logging_metric.relay_cell_reregistration_stalled", + "google_logging_metric.relay_cell_reserve_dead_man_tripped", + "google_logging_metric.relay_control_flags_unreadable", "google_logging_metric.relay_incident", "google_logging_metric.relay_long_cell_inventory_hold", + "google_logging_metric.relay_reserve_placement_off_with_reserve_cells", "google_logging_metric.relay_snapshot", "google_monitoring_alert_policy.relay_assignment_5xx", "google_monitoring_alert_policy.relay_assignment_edge_429", @@ -147,11 +152,15 @@ "google_monitoring_alert_policy.relay_cell_control_rtt", "google_monitoring_alert_policy.relay_cell_outdated_fix_level", "google_monitoring_alert_policy.relay_cell_pool_herd", + "google_monitoring_alert_policy.relay_cell_reregistration_gave_up", + "google_monitoring_alert_policy.relay_cell_reregistration_stalled", + "google_monitoring_alert_policy.relay_cell_reserve_dead_man_tripped", "google_monitoring_alert_policy.relay_cell_process_exit", "google_monitoring_alert_policy.relay_cloud_nat_port_drops", "google_monitoring_alert_policy.relay_cloud_sql_backends", "google_monitoring_alert_policy.relay_cloud_sql_checkpoint_loop", "google_monitoring_alert_policy.relay_cloud_sql_disk", + "google_monitoring_alert_policy.relay_control_flags_unreadable", "google_monitoring_alert_policy.relay_custom", "google_monitoring_alert_policy.relay_database_rejection_fenced", "google_monitoring_alert_policy.relay_director_cell_inventory_hold", @@ -162,6 +171,7 @@ "google_monitoring_alert_policy.relay_postgres_retry_exhausted", "google_monitoring_alert_policy.relay_process_fatal", "google_monitoring_alert_policy.relay_region_hint_skew", + "google_monitoring_alert_policy.relay_reserve_placement_off_with_reserve_cells", "google_monitoring_dashboard.relay_incident", "google_project_iam_custom_role.github_production_relay_capacity_mutation", "google_project_iam_custom_role.github_relay_asia_topology_mutation", diff --git a/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs b/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs index bbfe72ec2b4a..80b86aa4582b 100644 --- a/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs +++ b/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs @@ -24,6 +24,9 @@ export const PRODUCTION_GROUP = 'production-cloud-sql-rollout' export const STAGING_GROUP = 'relay-staging-mutation' export const SELECTABLE_GROUP = "${{ inputs.environment == 'production' && 'production-cloud-sql-rollout' || 'relay-staging-mutation' }}" +// Prove Relay Asia Staging's load mode drives traffic only and must not hold the staging lock. +export const STAGING_OR_LOAD_GROUP = + "${{ inputs.mode == 'load' && 'relay-staging-load' || 'relay-staging-mutation' }}" const selectable = (production, staging) => `\${{ inputs.environment == 'production' && '${production}' || '${staging}' }}` @@ -33,7 +36,12 @@ export const SELECTABLE_LEASE = { object: selectable(PRODUCTION_LEASE.object, STAGING_LEASE.object) } -export const LOCK_GROUPS = new Set([PRODUCTION_GROUP, STAGING_GROUP, SELECTABLE_GROUP]) +export const LOCK_GROUPS = new Set([ + PRODUCTION_GROUP, + STAGING_GROUP, + SELECTABLE_GROUP, + STAGING_OR_LOAD_GROUP +]) export function readWorkflow(file) { return readFileSync(new URL(file, WORKFLOW_ROOT), 'utf8') @@ -289,7 +297,7 @@ export const LEASED_WORKFLOWS = named([ ['deploy-relay-staging-gce-candidate.yml', staging()], ['bootstrap-relay-staging-capacity.yml', staging()], ['power-relay-staging.yml', staging()], - ['prove-relay-asia-staging.yml', staging()], + ['prove-relay-asia-staging.yml', staging({ group: STAGING_OR_LOAD_GROUP })], [ 'prove-relay-staging-capacity.yml', staging({ exclusiveBy: "inputs.mode == 'refresh-asia-c4-image'" }) diff --git a/cloud/dev/scripts/deploy-relay-blue-green.mjs b/cloud/dev/scripts/deploy-relay-blue-green.mjs index ddfe2bce8fc9..d93b2617e86f 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.mjs @@ -11,6 +11,11 @@ export const DIRECTOR_REGIONAL_PLACEMENT_SECRET = export const DIRECTOR_REGIONAL_PLACEMENT_ENV = 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' export const DIRECTOR_CORRECTION_COHORT_ENV = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' +// Step 5 placement on the directors; only this deploy changes it, so the reserve guard sees it. +export const DIRECTOR_RESERVE_PLACEMENT_ENV = 'ORCA_RELAY_RESERVE_PLACEMENT' +const RESERVE_PLACEMENT_MODES = ['off', 'dry-run', 'on'] +// The cells whose feeds a director polls; a reserve cell outside it has no placing director. +export const DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV = 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS' export const DIRECTOR_REHOME_IDENTITY_ENV = 'ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT' export const DIRECTOR_REHOME_AUDIENCE_ENV = 'ORCA_RELAY_REHOME_AUDIENCE' @@ -250,8 +255,15 @@ export function directorDeploymentEnvironment(config) { config['region-correction-cohort-percent'] !== 'preserve') { environment[DIRECTOR_CORRECTION_COHORT_ENV] = correctionCohortPercent(config['region-correction-cohort-percent']) } + if (config['reserve-placement'] !== undefined && config['reserve-placement'] !== 'preserve') { + environment[DIRECTOR_RESERVE_PLACEMENT_ENV] = config['reserve-placement'] + } + if (config['shadow-seat-feed-cells'] !== undefined && config['shadow-seat-feed-cells'] !== 'preserve') { + environment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] = shadowSeatFeedCellsSetting(config['shadow-seat-feed-cells']) + } const serviceAccount = projectServiceAccount(config, 'capacity-service-account') const asiaProofServiceAccount = projectServiceAccount(config, 'asia-proof-service-account') + const deployServiceAccount = projectServiceAccount(config, 'deploy-service-account') const rehomeDirectorServiceAccount = projectServiceAccount( config, 'rehome-director-service-account' @@ -263,6 +275,10 @@ export function directorDeploymentEnvironment(config) { if (asiaProofServiceAccount !== undefined) { environment.ORCA_RELAY_ASIA_PROOF_SERVICE_ACCOUNT = asiaProofServiceAccount } + // The admin identity the director trusts; omitted, the serving revision's value carries over. + if (deployServiceAccount !== undefined) { + environment.ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT = deployServiceAccount + } if (rehomeDirectorServiceAccount !== undefined) { environment[DIRECTOR_REHOME_IDENTITY_ENV] = rehomeDirectorServiceAccount environment[DIRECTOR_REHOME_AUDIENCE_ENV] = config['rehome-audience'] @@ -271,6 +287,16 @@ export function directorDeploymentEnvironment(config) { return environment } +// `all` or a comma list of cell ids, as the director's config reads it. +function shadowSeatFeedCellsSetting(value) { + if (value === 'all') return value + const cells = value.split(',').map((cell) => cell.trim()) + if (cells.length > 100 || cells.some((cell) => !/^[a-z][a-z0-9-]{0,39}$/.test(cell))) { + throw new Error('--shadow-seat-feed-cells must be preserve, all, or a comma list of cell ids') + } + return cells.join(',') +} + export function environmentUpdateValue(environment) { const entries = Object.entries(environment) if (entries.every(([key, value]) => !key.includes(',') && !value.includes(','))) { @@ -309,17 +335,26 @@ export function parseArguments(argv) { } if ( values['capacity-service-account'] !== undefined || + values['deploy-service-account'] !== undefined || values['director-cells-json'] !== undefined || values['runtime-service-account'] !== undefined || values['rehome-director-service-account'] !== undefined || values['rehome-audience'] !== undefined || values['expected-rehome-generation'] !== undefined || values['rehome-control-origin'] !== undefined || - values['region-correction-cohort-percent'] !== undefined + values['region-correction-cohort-percent'] !== undefined || + values['reserve-placement'] !== undefined || + values['shadow-seat-feed-cells'] !== undefined ) { throw new Error('director configuration arguments require --role director') } } + if ( + values['reserve-placement'] !== undefined && + !['preserve', ...RESERVE_PLACEMENT_MODES].includes(values['reserve-placement']) + ) { + throw new Error('--reserve-placement must be preserve, off, dry-run or on') + } if ( values.role === 'director' && values['capacity-cell-id'] !== undefined && @@ -378,12 +413,14 @@ export function parseArguments(argv) { throw new Error('--rehome-audience must be an exact host-drain HTTPS URL') } } - const controlArguments = [ - 'expected-rehome-generation', - 'rehome-control-origin', - 'admin-audience' - ].map((key) => values[key] !== undefined) - if (controlArguments.some(Boolean) && !controlArguments.every(Boolean)) { + // --admin-audience alone serves the reserve guard's reads; durable verification needs all three. + const controlArguments = ['expected-rehome-generation', 'rehome-control-origin'].map( + (key) => values[key] !== undefined + ) + if ( + controlArguments.some(Boolean) && + (!controlArguments.every(Boolean) || values['admin-audience'] === undefined) + ) { throw new Error('durable rehome verification arguments must be configured together') } if ( @@ -429,9 +466,35 @@ export function suppliedAdminIdentityToken(environment = process.env) { return token } +// The unverified `email` claim, only to name the caller in an error; the director verifies it. +export function adminTokenEmail(token) { + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')) + return typeof payload?.email === 'string' ? payload.email : undefined + } catch { + return undefined + } +} + +// cell-admit-mode answers only the revision's deploy identity, so a drifted one (a revision that +// trusts another account than the workflow's) fails here by name instead of as a bare 401. +export function assertDirectorTrustsAdminCaller(token, environment, label) { + const trusted = environment.ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT + const caller = adminTokenEmail(token) + if (trusted === undefined || caller === undefined || caller === trusted) return + throw new Error( + `the ${label} director trusts ${trusted} for deploy admin reads, but this deploy authenticates as ${caller}; ` + + 'run as that identity or correct the revision\'s ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT' + ) +} + function adminIdentityToken(config) { + const supplied = suppliedAdminIdentityToken() + if (supplied === null && !config['admin-audience']) { + throw new Error('admin reads need --admin-audience or a supplied ORCA_RELAY_ADMIN_ID_TOKEN') + } return ( - suppliedAdminIdentityToken() ?? + supplied ?? commandText(['auth', 'print-identity-token', `--audiences=${config['admin-audience']}`], { sensitive: true }) @@ -721,6 +784,108 @@ export async function assertRegionalRehomeDisabled( return result.control } +// `undefined` when the director predates the reserve census (its router 404s the route). +export async function readReserveCells(config, origin, cellIds, fetchImpl = fetch) { + const reserve = [] + for (const cellId of cellIds) { + const response = await fetchImpl(`${origin}/v1/admin/cell-admit-mode`, { + method: 'POST', + headers: { + authorization: `Bearer ${adminIdentityToken(config)}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ v: 1, cellId }), + signal: AbortSignal.timeout(30_000) + }) + const result = await response.json().catch(() => null) + if (response.status === 404 && result === null) return undefined + if (!response.ok || result?.v !== 1 || !['db', 'reserve'].includes(result.admitMode)) { + throw new Error(`cell admit mode read failed for ${cellId}: ${result?.error ?? response.status}`) + } + if (result.admitMode === 'reserve') reserve.push(cellId) + } + return reserve +} + +// A director without the census places hosts on reserve cells from stale rows, so traffic may +// move between revisions that differ in it only while every cell is db. +export function assertReserveCompatibleTrafficMove(servingReserve, candidateReserve) { + if ((servingReserve === undefined) === (candidateReserve === undefined)) return + const reserve = servingReserve ?? candidateReserve + if (reserve.length > 0) { + throw new Error( + `director revisions differ in reserve-mode support while cells are reserve (${reserve.join(', ')}); flip them to db first` + ) + } +} + +// With placement off no director books a reserve cell, so each one's dead-man trips (60-120 s) +// and every host re-registers at once: the cells go to db first, on purpose. +export function assertReservePlacementKept(servingPlacement, candidatePlacement, reserve) { + if (servingPlacement !== 'on' || candidatePlacement === 'on') return + if (reserve !== undefined && reserve.length > 0) { + throw new Error( + `reserve placement would turn ${candidatePlacement} while cells are reserve (${reserve.join(', ')}); flip them to db first` + ) + } +} + +// Placement as the director built it (runtime-status), not as its env asks; an image from before +// the field reports nothing, and only its env can say. +export async function readDirectorPlacement(config, origin, fetchImpl = fetch) { + const response = await fetchImpl(`${origin}/v1/admin/runtime-status`, { + method: 'POST', + headers: { + authorization: `Bearer ${adminIdentityToken(config)}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ v: 1 }), + signal: AbortSignal.timeout(30_000) + }) + const result = await response.json().catch(() => null) + if (!response.ok || result?.v !== 1 || result.role !== 'director') { + throw new Error(`director runtime status failed at ${origin}: ${result?.error ?? response.status}`) + } + if (result.reservePlacement === undefined) return 'unreported' + if (!RESERVE_PLACEMENT_MODES.includes(result.reservePlacement)) { + throw new Error(`director at ${origin} reports reserve placement ${result.reservePlacement}`) + } + return result.reservePlacement +} + +const shadowCellSet = (value) => + (value ?? '').trim() === 'all' + ? 'all' + : new Set( + (value ?? '') + .split(',') + .map((cell) => cell.trim()) + .filter(Boolean) + ) + +// A reserve cell the directors stop polling gets no booking and no reserver poll: its dead-man +// trips. Shrinking the shadow-cell list past one is refused until that cell is db. +export function assertShadowCellsKeepReserveCells(servingCells, candidateCells, reserve) { + if (reserve === undefined || reserve.length === 0) return + const before = shadowCellSet(servingCells) + const after = shadowCellSet(candidateCells) + if (after === 'all') return + const dropped = reserve.filter((cell) => (before === 'all' || before.has(cell)) && !after.has(cell)) + if (dropped.length > 0) { + throw new Error( + `the shadow seat-feed cells would drop reserve cells (${dropped.join(', ')}); flip them to db first` + ) + } +} + +function directorCellIds(...environments) { + const ids = new Set() + for (const environment of environments) { + for (const cell of JSON.parse(environment.ORCA_RELAY_CELLS_JSON ?? '[]')) ids.add(cell.id) + } + return [...ids] +} + function assertDirectorRevisionIdentity(revision, config) { const expectedRuntimeServiceAccount = config['runtime-service-account'] if ( @@ -741,6 +906,9 @@ export async function deployDirector(config, tag, overrides = {}) { updateTraffic, waitForHealth, assertRegionalRehomeDisabled, + readReserveCells, + readDirectorPlacement, + adminIdentityToken, ...overrides } // Why: gcloud does not carry minScale onto a new revision, and the candidate below takes @@ -801,6 +969,12 @@ export async function deployDirector(config, tag, overrides = {}) { deploymentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ??= correctionCohortPercent( currentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ?? '0' ) + if (currentEnvironment[DIRECTOR_RESERVE_PLACEMENT_ENV] !== undefined) { + deploymentEnvironment[DIRECTOR_RESERVE_PLACEMENT_ENV] ??= currentEnvironment[DIRECTOR_RESERVE_PLACEMENT_ENV] + } + if (currentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] !== undefined) { + deploymentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] ??= currentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] + } if (config['region-correction-cohort-percent'] !== undefined && config['region-correction-cohort-percent'] !== 'preserve' && config['expected-rehome-generation'] === undefined) { @@ -911,6 +1085,46 @@ export async function deployDirector(config, tag, overrides = {}) { } await operations.waitForHealth(candidate.origin, requiredCapacityProtocol) await verifyRehomeDisabled(candidate.origin) + // The same image and the same placement and shadow-cell settings support the same things; + // anything else needs every cell's admit mode (and the admin token that reads it). + const sameImage = + servingRevision.spec?.containers?.[0]?.image?.split('@').at(-1) === config.image?.split('@').at(-1) + const sameSetting = (name) => (currentEnvironment[name] ?? '') === (environment[name] ?? '') + if (sameImage && sameSetting(DIRECTOR_RESERVE_PLACEMENT_ENV) && sameSetting(DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV)) { + console.warn(JSON.stringify({ event: 'director_reserve_support_checked', skipped: 'same image and settings' })) + } else { + const servingOrigin = initialService.status?.url + if (!servingOrigin) throw new Error('relay service reports no URL for the serving revision') + const token = operations.adminIdentityToken(config) + assertDirectorTrustsAdminCaller(token, currentEnvironment, 'serving') + assertDirectorTrustsAdminCaller(token, environment, 'candidate') + const cellIds = directorCellIds(currentEnvironment, environment) + const servingReserve = await operations.readReserveCells(config, servingOrigin, cellIds) + const candidateReserve = await operations.readReserveCells(config, candidate.origin, cellIds) + const reserve = candidateReserve ?? servingReserve + // Placement as each director built it, which an env string alone does not say. + const placementAt = async (origin, env) => { + const placement = await operations.readDirectorPlacement(config, origin) + return placement === 'unreported' ? (env[DIRECTOR_RESERVE_PLACEMENT_ENV] ?? 'off') : placement + } + const servingPlacement = await placementAt(servingOrigin, currentEnvironment) + const candidatePlacement = await placementAt(candidate.origin, environment) + assertReserveCompatibleTrafficMove(servingReserve, candidateReserve) + assertReservePlacementKept(servingPlacement, candidatePlacement, reserve) + assertShadowCellsKeepReserveCells( + currentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], + environment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], + reserve + ) + console.warn( + JSON.stringify({ + event: 'director_reserve_support_checked', + reservePlacement: { serving: servingPlacement, candidate: candidatePlacement }, + serving: servingReserve === undefined ? 'unsupported' : servingReserve, + candidate: candidateReserve === undefined ? 'unsupported' : candidateReserve + }) + ) + } operations.updateTraffic(config, [`--to-tags=${tag}=100`]) promoted = true removeDirectorTrafficTags(config, operations, new Set([SELECTOR_ROLLBACK_TAG])) diff --git a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs index a68ce50e9120..4ab531b58a7d 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs @@ -13,13 +13,19 @@ import { DIRECTOR_REHOME_AUDIENCE_ENV, DIRECTOR_REHOME_IDENTITY_ENV, assertRegionalRehomeDisabled, + assertReserveCompatibleTrafficMove, + assertShadowCellsKeepReserveCells, deployDirector, + DIRECTOR_RESERVE_PLACEMENT_ENV, directorDeploymentEnvironment, + DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV, directorCellSetAddition, directorStartupProbeArguments, directorTopologyChange, environmentUpdateValue, parseArguments, + readDirectorPlacement, + readReserveCells, revisionEnvironment, revisionSecretEnvironment, suppliedAdminIdentityToken, @@ -418,6 +424,9 @@ test('requires exactly one active revision and reads queried tag metadata', () = assert.deepEqual(trafficTags(service), ['candidate-123']) }) +const adminToken = (email) => + `e30.${Buffer.from(JSON.stringify(email ? { email } : {})).toString('base64url')}.sig` + function directorHarness({ deployFailure, deleteFailure, @@ -428,7 +437,11 @@ function directorHarness({ // Reproduces gcloud dropping minScale from a newly created revision. dropRequestedMinimum = false, servingServiceAccount, - servingImageDigest = `sha256:${'f'.repeat(64)}` + servingImageDigest = `sha256:${'f'.repeat(64)}`, + reserveCells = {}, + servingEnvironment = {}, + placements = {}, + callerEmail } = {}) { const state = { activeRevision: 'relay-00001-old', @@ -439,7 +452,8 @@ function directorHarness({ 'relay-00001-old', { env: { - ORCA_RELAY_ROLE: 'director' + ORCA_RELAY_ROLE: 'director', + ...servingEnvironment }, secrets: { [DIRECTOR_REGIONAL_PLACEMENT_ENV]: { @@ -462,6 +476,7 @@ function directorHarness({ const operations = { describeService: () => ({ status: { + url: 'https://relay-hash-uc.a.run.app', traffic: [ { percent: 100, revisionName: state.activeRevision }, ...[...state.tags].map(([tag, revisionName]) => ({ @@ -501,7 +516,8 @@ function directorHarness({ deployCandidate: (config, tag, env, image, minimum, maximum, regionalVersion) => { const revision = `relay-${String(state.nextRevision++).padStart(5, '0')}-new` state.revisions.set(revision, { - env: { ORCA_RELAY_ROLE: 'director', ...env }, + // gcloud --update-env-vars keeps every variable the deploy does not name. + env: { ORCA_RELAY_ROLE: 'director', ...servingEnvironment, ...env }, secrets: { [DIRECTOR_REGIONAL_PLACEMENT_ENV]: { secret: DIRECTOR_REGIONAL_PLACEMENT_SECRET, @@ -544,7 +560,10 @@ function directorHarness({ }, waitForHealth: async (_origin, connectionCapacityProtocol) => { healthProtocols.push(connectionCapacityProtocol) - } + }, + readReserveCells: async (_config, origin) => reserveCells[origin], + readDirectorPlacement: async (_config, origin) => placements[origin] ?? 'unreported', + adminIdentityToken: () => adminToken(callerEmail) } return { state, removed, healthProtocols, operations } } @@ -854,3 +873,230 @@ test('sets a reviewed cohort only behind repeated disabled-control verification' assert.ok(verified >= 2) assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '1') }) + +test('director deploy refuses a traffic move between reserve-support revisions while a cell is reserve', async () => { + const candidateOrigin = 'https://candidate-new---relay-hash-uc.a.run.app' + const oldServing = directorHarness({ + reserveCells: { [candidateOrigin]: ['production-gce-c3'] } + }) + await assert.rejects( + deployDirector({}, 'candidate-new', oldServing.operations), + /differ in reserve-mode support while cells are reserve \(production-gce-c3\)/ + ) + assert.equal(oldServing.state.activeRevision, 'relay-00001-old') + const rollbackToOld = directorHarness({ + reserveCells: { 'https://relay-hash-uc.a.run.app': ['production-gce-c3'] } + }) + await assert.rejects(deployDirector({}, 'candidate-new', rollbackToOld.operations), /flip them to db first/) + assert.equal(rollbackToOld.state.activeRevision, 'relay-00001-old') + const allDb = directorHarness({ reserveCells: { [candidateOrigin]: [] } }) + await deployDirector({}, 'candidate-new', allDb.operations) + assert.equal(allDb.state.activeRevision, 'relay-00003-new') +}) + +test('two revisions that both read the census may move traffic with reserve cells', () => { + assert.doesNotThrow(() => assertReserveCompatibleTrafficMove(['c3'], ['c3'])) + assert.doesNotThrow(() => assertReserveCompatibleTrafficMove(undefined, undefined)) + assert.throws(() => assertReserveCompatibleTrafficMove(undefined, ['c3']), /flip them to db first/) +}) + +test('reads a census-less director as unsupported and fails closed on any other refusal', async () => { + const environment = process.env.ORCA_RELAY_ADMIN_ID_TOKEN + process.env.ORCA_RELAY_ADMIN_ID_TOKEN = 'aaa.bbb.ccc' + const config = { 'admin-audience': 'https://relay.onorca.dev/v1/admin/drain' } + try { + assert.equal( + await readReserveCells(config, 'https://old.example.test', ['c1'], async () => + new Response('404 Not Found', { status: 404 }) + ), + undefined + ) + const modes = { c1: 'db', c2: 'reserve' } + assert.deepEqual( + await readReserveCells(config, 'https://new.example.test', ['c1', 'c2'], async (url, init) => { + assert.equal(url, 'https://new.example.test/v1/admin/cell-admit-mode') + const { cellId } = JSON.parse(init.body) + return new Response(JSON.stringify({ v: 1, cellId, admitMode: modes[cellId], updatedAt: 1 })) + }), + ['c2'] + ) + await assert.rejects( + readReserveCells(config, 'https://new.example.test', ['c1'], async () => + new Response(JSON.stringify({ error: 'invalid_token' }), { status: 401 }) + ), + /cell admit mode read failed for c1: invalid_token/ + ) + } finally { + if (environment === undefined) delete process.env.ORCA_RELAY_ADMIN_ID_TOKEN + else process.env.ORCA_RELAY_ADMIN_ID_TOKEN = environment + } +}) + +test('a same-image deploy reads no admit modes, and keeps the serving reserve placement', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const harness = directorHarness({ servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' } }) + let reads = 0 + harness.operations.readReserveCells = async () => { + reads += 1 + return [] + } + await deployDirector({ image: `relay@${servingImageDigest}` }, 'candidate-new', harness.operations) + assert.equal(reads, 0) + assert.equal(harness.state.revisions.get(harness.state.activeRevision).env[DIRECTOR_RESERVE_PLACEMENT_ENV], 'on') +}) + +test('turning reserve placement off is refused while any cell is reserve, even on the same image', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const candidateOrigin = 'https://candidate-new---relay-hash-uc.a.run.app' + const reserved = directorHarness({ + servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': ['production-gce-c3'], [candidateOrigin]: ['production-gce-c3'] }, + placements: { 'https://relay-hash-uc.a.run.app': 'on', [candidateOrigin]: 'off' } + }) + await assert.rejects( + deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', reserved.operations), + /reserve placement would turn off while cells are reserve \(production-gce-c3\); flip them to db first/ + ) + assert.equal(reserved.state.activeRevision, 'relay-00001-old') + const allDb = directorHarness({ + servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': [], [candidateOrigin]: [] } + }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', allDb.operations) + assert.equal(allDb.state.revisions.get(allDb.state.activeRevision).env[DIRECTOR_RESERVE_PLACEMENT_ENV], 'off') +}) + +test('--reserve-placement takes only a known mode, and only for a director', () => { + const base = ['--project', 'p', '--region', 'r', '--service', 's', '--image', `relay@sha256:${'a'.repeat(64)}`, '--release-id', '1'] + assert.equal(parseArguments([...base, '--role', 'director', '--reserve-placement', 'on'])['reserve-placement'], 'on') + assert.throws(() => parseArguments([...base, '--role', 'director', '--reserve-placement', 'yes']), /must be preserve, off, dry-run or on/) + assert.throws( + () => parseArguments([...base, '--role', 'cell', '--director-origin', 'https://d', '--admin-audience', 'a', '--reserve-placement', 'on']), + /require --role director/ + ) + assert.equal(directorDeploymentEnvironment({ 'reserve-placement': 'preserve' })[DIRECTOR_RESERVE_PLACEMENT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'reserve-placement': 'dry-run' })[DIRECTOR_RESERVE_PLACEMENT_ENV], 'dry-run') +}) + +test('the guard reads placement as built, so a director that never placed does not block turning it off', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const candidateOrigin = 'https://candidate-new---relay-hash-uc.a.run.app' + // Configured on, but the serving director reports off: it was never feeding the dead-man. + const harness = directorHarness({ + servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': ['production-gce-c3'], [candidateOrigin]: ['production-gce-c3'] }, + placements: { 'https://relay-hash-uc.a.run.app': 'off', [candidateOrigin]: 'off' } + }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', harness.operations) + assert.equal(harness.state.revisions.get(harness.state.activeRevision).env[DIRECTOR_RESERVE_PLACEMENT_ENV], 'off') +}) + +test('a shadow-cell change on the same image is checked, and a shrink past a reserve cell is refused', () => { + assert.throws( + () => assertShadowCellsKeepReserveCells('production-gce-c1,production-gce-c3', 'production-gce-c1', ['production-gce-c3']), + /would drop reserve cells \(production-gce-c3\); flip them to db first/ + ) + assert.throws(() => assertShadowCellsKeepReserveCells('all', 'production-gce-c1', ['production-gce-c3']), /production-gce-c3/) + assert.throws(() => assertShadowCellsKeepReserveCells('all', undefined, ['production-gce-c3']), /production-gce-c3/) + assert.doesNotThrow(() => assertShadowCellsKeepReserveCells('production-gce-c1', 'all', ['production-gce-c3'])) + assert.doesNotThrow(() => assertShadowCellsKeepReserveCells('production-gce-c1,production-gce-c3', 'production-gce-c1', [])) + // A reserve cell the serving list never polled is not being dropped by this deploy. + assert.doesNotThrow(() => assertShadowCellsKeepReserveCells('production-gce-c1', 'production-gce-c1', ['production-gce-c3'])) +}) + +test('reads a director placement from runtime-status, and reports an image without the field', async () => { + const environment = process.env.ORCA_RELAY_ADMIN_ID_TOKEN + process.env.ORCA_RELAY_ADMIN_ID_TOKEN = 'aaa.bbb.ccc' + const config = { 'admin-audience': 'https://relay.onorca.dev/v1/admin/drain' } + try { + const answer = (body) => async () => new Response(JSON.stringify({ v: 1, role: 'director', ...body })) + assert.equal(await readDirectorPlacement(config, 'https://d.example.test', answer({ reservePlacement: 'on' })), 'on') + assert.equal(await readDirectorPlacement(config, 'https://d.example.test', answer({})), 'unreported') + await assert.rejects(readDirectorPlacement(config, 'https://d.example.test', answer({ reservePlacement: 'maybe' })), /reports reserve placement maybe/) + await assert.rejects( + readDirectorPlacement(config, 'https://d.example.test', async () => new Response('{}', { status: 401 })), + /runtime status failed/ + ) + } finally { + if (environment === undefined) delete process.env.ORCA_RELAY_ADMIN_ID_TOKEN + else process.env.ORCA_RELAY_ADMIN_ID_TOKEN = environment + } +}) + +// Why: staging drifted to a director that trusts gha-deploy while every staging workflow +// authenticates as gha-relay; the deploy, not Terraform, owns the live value. +test('a director deploy sets the trusted deploy identity only when asked', () => { + const config = { project: 'onorca-cloud-staging' } + const relay = 'orca-cloud-staging-gha-relay@onorca-cloud-staging.iam.gserviceaccount.com' + assert.equal( + directorDeploymentEnvironment({ ...config, 'deploy-service-account': relay }) + .ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT, + relay + ) + assert.equal( + 'ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT' in directorDeploymentEnvironment(config), + false + ) + assert.throws( + () => + directorDeploymentEnvironment({ + ...config, + 'deploy-service-account': 'foreign@other-project.iam.gserviceaccount.com' + }), + /selected project/ + ) +}) + +// Staging's director trusted gha-deploy while the workflow authenticated as gha-relay: a bare 401. +test('the guard names the deploy identity a drifted director trusts instead of reading into a 401', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const harness = directorHarness({ + servingEnvironment: { ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'gha-deploy@p.iam.gserviceaccount.com' }, + callerEmail: 'gha-relay@p.iam.gserviceaccount.com' + }) + await assert.rejects( + deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', harness.operations), + /serving director trusts gha-deploy@p\.iam\.gserviceaccount\.com for deploy admin reads, but this deploy authenticates as gha-relay@p\.iam\.gserviceaccount\.com/ + ) + const trusted = directorHarness({ + servingEnvironment: { ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'gha-relay@p.iam.gserviceaccount.com' }, + callerEmail: 'gha-relay@p.iam.gserviceaccount.com' + }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', trusted.operations) +}) + +test('a director deploy takes --admin-audience alone for the reserve guard', () => { + const base = ['--project', 'p', '--region', 'r', '--service', 's', '--image', `relay@sha256:${'e'.repeat(64)}`, '--role', 'director', '--release-id', 'x'] + const audience = ['--admin-audience', 'https://relay.example.test'] + assert.equal(parseArguments([...base, ...audience])['admin-audience'], 'https://relay.example.test') + assert.throws( + () => parseArguments([...base, '--expected-rehome-generation', '1', '--rehome-control-origin', 'https://relay.example.test']), + /durable rehome verification arguments must be configured together/ + ) + assert.throws( + () => parseArguments([...base, ...audience, '--expected-rehome-generation', '1']), + /durable rehome verification arguments must be configured together/ + ) +}) + +test('--shadow-seat-feed-cells sets the director setting, and preserve keeps the serving one', async () => { + assert.equal( + directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'c1, c2' })[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], + 'c1,c2' + ) + assert.equal(directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'all' })[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], 'all') + assert.equal(directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'preserve' })[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], undefined) + assert.throws(() => directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'C1;x' }), /must be preserve, all, or a comma list/) + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const harness = directorHarness({ servingEnvironment: { [DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV]: 'c1,c3' } }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'shadow-seat-feed-cells': 'preserve' }, 'candidate-new', harness.operations) + assert.equal(harness.state.revisions.get(harness.state.activeRevision).env[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], 'c1,c3') + const shrink = directorHarness({ + servingEnvironment: { [DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV]: 'c1,c3' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': ['c3'], 'https://candidate-new---relay-hash-uc.a.run.app': ['c3'] } + }) + await assert.rejects( + deployDirector({ image: `relay@${servingImageDigest}`, 'shadow-seat-feed-cells': 'c1' }, 'candidate-new', shrink.operations), + /would drop reserve cells \(c3\)/ + ) +}) diff --git a/cloud/dev/scripts/load-relay-controls.mjs b/cloud/dev/scripts/load-relay-controls.mjs index e78ebdb4b2b5..59a1714f3ed0 100644 --- a/cloud/dev/scripts/load-relay-controls.mjs +++ b/cloud/dev/scripts/load-relay-controls.mjs @@ -20,6 +20,7 @@ import { waitForRelayLoadRebindGate } from './relay-load-rebind-boundary.mjs' import { proveRelayLoadRegionBehavior } from './relay-load-region-behavior.mjs' +import { proveRelayLoadDuplicateAssign } from './relay-load-duplicate-assign.mjs' import { openRelayLoadInviteOffers, proveRelayLoadRequestUnitBoundary @@ -95,6 +96,7 @@ function report(state, final = false) { regionalFallbacksProved: state.regionalFallbacksProved, oldClientUsFirstProved: state.oldClientUsFirstProved, stickyAssignmentProved: state.stickyAssignmentProved, + duplicateAssign: state.duplicateAssign, requestUnitInvitesOpened: state.requestUnitInvitesOpened, requestUnitPrincipalCount: state.requestUnitPrincipalCount, relayAsiaLoadPrincipalCount: state.relayAsiaLoadPrincipalCount, @@ -231,6 +233,7 @@ const state = { regionalFallbacksProved: 0, oldClientUsFirstProved: 0, stickyAssignmentProved: 0, + duplicateAssign: null, requestUnitInvitesOpened: 0, requestUnitOverflowReason: null, requestUnitCleanupProved: 0, @@ -255,6 +258,8 @@ const state = { } const peers = new Map() const reconnectTimers = new Set() +// Duplicate-assign probe hosts: outside `peers`, so they never count toward the run's controls. +const probePeers = [] async function readRuntimeQueuedBytes(origin) { const response = await fetch(`${origin}/v1/admin/runtime-status`, { @@ -396,6 +401,47 @@ progressTimer.unref() await runRelayLoadWithShutdown(async () => { await Promise.all(initialConnections) assertRelayLoadRampAccepted(state.rampConnectionFailures, config.maxRampConnectionFailures) + if (config.duplicateAssignProbes > 0) { + const reconnects = { closes: 0, closesByCode: {}, reconnectFailures: 0 } + // A probe host closed by a demotion re-assigns through the director, as a desktop would. + const observeProbe = (peer) => (type, detail) => { + if (type !== 'closed' || detail.stopped) return + reconnects.closes++ + const code = String(detail.code) + reconnects.closesByCode[code] = (reconnects.closesByCode[code] ?? 0) + 1 + if (state.stopping) return + const timeout = setTimeout(() => { + reconnectTimers.delete(timeout) + void peer.connect().catch(() => { + reconnects.reconnectFailures++ + }) + }, 1_000 + Math.floor(Math.random() * 5_000)) + reconnectTimers.add(timeout) + } + const base = config.controls * config.shardCount + 20_000 + const pairs = Array.from({ length: config.duplicateAssignProbes }, (_, probe) => { + const index = base + probe * config.shardCount + config.shardIndex + const primary = new RelayLoadControlPeer(index, peerOptions(index), (type, detail) => + observeProbe(primary)(type, detail) + ) + const twin = new RelayLoadControlPeer( + index, + peerOptions(index, { keys: primary.keys }), + () => undefined + ) + probePeers.push(primary, twin) + return { primary, twin } + }) + state.duplicateAssign = { + ...(await proveRelayLoadDuplicateAssign({ + pairs, + holdMs: config.duplicateAssignHoldMs, + delay, + failureReason: relayLoadFailureReason + })), + probeReconnects: reconnects + } + } if ( config.rebindProbes > 0 || config.placementOverflowProbes > 0 || config.regionalFallbackProbes > 0 @@ -527,7 +573,7 @@ await runRelayLoadWithShutdown(async () => { clearInterval(progressTimer) for (const timeout of reconnectTimers) clearTimeout(timeout) reconnectTimers.clear() - await Promise.all([...peers.values()].map((peer) => peer.shutdown())) + await Promise.all([...peers.values(), ...probePeers].map((peer) => peer.shutdown())) eventLoopDelay.disable() state.shutdownEvidence = { peerShutdowns: state.peerShutdowns, diff --git a/cloud/dev/scripts/operate-relay-cell-flags.mjs b/cloud/dev/scripts/operate-relay-cell-flags.mjs index 01f1ae56a10b..1d962b4d2bde 100644 --- a/cloud/dev/scripts/operate-relay-cell-flags.mjs +++ b/cloud/dev/scripts/operate-relay-cell-flags.mjs @@ -16,18 +16,144 @@ export const READ_BACK_TIMEOUT_MS = 15_000 const READ_BACK_INTERVAL_MS = 1_000 const STORAGE_ROOT = 'https://storage.googleapis.com' +// Every switch a cell image may know, with what an absent key means on the cell. A cell +// resets a key missing from its object to this default, so a write must carry every key it +// does not mean to change: the tool reads the current object and changes only what is named. +const enumOf = (...options) => (value) => (options.includes(value) ? value : undefined) +const booleanValue = (value) => (value === 'true' ? true : value === 'false' ? false : undefined) +const numberIn = (min, max, integer) => (value) => { + const number = Number(value) + if (value === '' || !Number.isFinite(number) || number < min || number > max) return undefined + if (integer && !Number.isInteger(number)) return undefined + return number +} +export const CELL_FLAG_SPECS = { + readinessLocal: { parse: booleanValue, default: false }, + ticketCheck: { parse: enumOf('off', 'shadow', 'enforce'), default: 'off' }, + admitMode: { parse: enumOf('db', 'reserve'), default: 'db' }, + // Absent means the cell's region default. + intakePerSec: { + parse: (value) => { + const rate = numberIn(0, 1_000, false)(value) + return rate === 0 ? undefined : rate + } + }, + reserveDryRun: { parse: booleanValue, default: false }, + rejectionFence: { parse: booleanValue, default: true }, + // Absent keeps the cell's boot value. + readTimeoutMarginMs: { parse: numberIn(1_000, 60_000, true) }, + // Flip-back leases in flight; absent is a third of the cell's pool. + reregisterInFlight: { parse: numberIn(1, 16, true) } +} + +export const RESERVE_CONFIRMATION = 'RESERVE' +export const BREAK_GLASS_CONFIRMATION = 'BREAK GLASS' +// Longer than a director's cached read of the admit-mode table (5 s). +export const RESERVE_MODE_CACHE_WAIT_MS = 10_000 +// How long a flip back may take to lease every control the cell admitted from memory: its +// control count at a per-lane pace below the measured 0.38/s (170 ms trips), within the job. +export const REREGISTER_WAIT_MIN_MS = 8 * 60_000 +export const REREGISTER_WAIT_MAX_MS = 50 * 60_000 +const REREGISTER_CONTROLS_PER_LANE_PER_SEC = 0.3 +// The cell takes a third of its database pool, and caps the switch at the pool less two +// (host-session-registry.ts reregisterInFlightLimit). An image from before runtime-status +// reported the pool falls back to the deployed sizes (US 10, Asia 16). +const CELL_DATABASE_POOL_MAX = { 'asia-east2': 16 } +const CELL_DATABASE_POOL_MAX_DEFAULT = 10 + +export function reregisterLanes(runtime) { + const reported = runtime?.databasePoolMax + const pool = + Number.isInteger(reported) && reported > 0 + ? reported + : (CELL_DATABASE_POOL_MAX[runtime?.region] ?? CELL_DATABASE_POOL_MAX_DEFAULT) + const flag = runtime?.flagsApplied?.flags?.reregisterInFlight + if (flag === undefined) return Math.max(1, Math.floor(pool / 3)) + return Math.max(1, Math.min(Number(flag), pool - 2)) +} + +export function reregisterWaitMs(runtime) { + const controls = Number(runtime?.runtime?.controls ?? 0) + const lanes = reregisterLanes(runtime) + const estimate = (controls / (lanes * REREGISTER_CONTROLS_PER_LANE_PER_SEC)) * 1_000 + return Math.min(REREGISTER_WAIT_MAX_MS, Math.max(REREGISTER_WAIT_MIN_MS, Math.ceil(estimate))) +} + +// An image from before `supportedFlags` accepts the keys its applied flags list, and only +// ticketCheck off/shadow: anything else is dropped there, or voids the whole object. +function legacySupportedFlags(appliedFlags) { + return Object.fromEntries( + Object.entries(appliedFlags ?? {}).map(([key, value]) => [ + key, + key === 'ticketCheck' + ? { type: 'enum', values: ['off', 'shadow'] } + : { type: typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'enum', values: [value] } + ]) + ) +} + +function accepts(spec, value) { + if (spec?.type === 'boolean') return typeof value === 'boolean' + if (spec?.type === 'enum') return spec.values.includes(value) + if (spec?.type === 'number') { + return ( + typeof value === 'number' && + (spec.min === undefined || value >= spec.min) && + (spec.max === undefined || value <= spec.max) && + (!spec.integer || Number.isInteger(value)) + ) + } + return false +} + +// The whole object as it will be written, not only the change: a bad key or value already in +// the file would void or be dropped on every later write too. +export function assertSupportedObject(flags, runtime) { + const supported = runtime.supportedFlags ?? legacySupportedFlags(runtime.flagsApplied?.flags) + for (const [key, value] of Object.entries(flags)) { + if (!accepts(supported[key], value)) { + throw new Error( + `the cell's image does not support ${key}=${JSON.stringify(value)}; roll the image first` + ) + } + } +} + +// `key=value,key=value`; `key=default` removes the key so the cell uses its default. +export function parseFlagChanges(text) { + const changes = {} + for (const part of (text ?? '').split(',').map((entry) => entry.trim()).filter(Boolean)) { + const separator = part.indexOf('=') + const key = separator < 0 ? part : part.slice(0, separator) + const raw = separator < 0 ? '' : part.slice(separator + 1).trim() + const spec = CELL_FLAG_SPECS[key] + if (!spec) throw new Error(`unknown switch ${key}`) + if (key in changes) throw new Error(`switch ${key} named twice`) + if (raw === 'default') { + changes[key] = null + continue + } + const value = spec.parse(raw) + if (value === undefined) throw new Error(`switch ${key} cannot be ${JSON.stringify(raw)}`) + changes[key] = value + } + if (Object.keys(changes).length === 0) throw new Error('set names no switch to change') + return changes +} + +// The switches as the cell will apply them: every known key, absent ones at their default. +function effectiveFlags(flags) { + return Object.fromEntries( + Object.entries(CELL_FLAG_SPECS).map(([key, spec]) => [key, flags?.[key] ?? spec.default]) + ) +} + export function parseCellFlagsRequest(values) { const mode = values.mode if (mode !== 'dry-run' && mode !== 'write') throw new Error('mode must be dry-run or write') const cellId = values['cell-id'] ?? '' if (!CELL_ID_PATTERN.test(cellId)) throw new Error('cell id is not a relay GCE cell id') - if (values['readiness-local'] !== 'true' && values['readiness-local'] !== 'false') { - throw new Error('readiness-local must be true or false') - } - const ticketCheck = values['ticket-check'] - if (ticketCheck !== 'off' && ticketCheck !== 'shadow') { - throw new Error('ticket-check must be off or shadow') - } + const changes = parseFlagChanges(values.set) // 0 means "no object yet": the write then creates it and refuses if one appeared. const expectedGeneration = values['expected-generation'] ?? '' if (!/^(0|[1-9][0-9]{0,18})$/.test(expectedGeneration)) { @@ -36,6 +162,29 @@ export function parseCellFlagsRequest(values) { if (mode === 'write' && values.confirmation !== `${WRITE_CONFIRMATION} ${cellId}`) { throw new Error(`write requires the confirmation "${WRITE_CONFIRMATION} ${cellId}"`) } + // Reserve moves this cell's hosts off the database: it is typed for this cell, every time. + if ( + mode === 'write' && + changes.admitMode === 'reserve' && + values['confirm-reserve'] !== `${RESERVE_CONFIRMATION} ${cellId}` + ) { + throw new Error(`admitMode=reserve requires --confirm-reserve "${RESERVE_CONFIRMATION} ${cellId}"`) + } + // Break glass: a dead or unreachable reserve cell cannot report db, so nothing would ever + // let the sweeps re-place its hosts. Only admitMode=db, typed for this cell. + const breakGlass = values['break-glass'] ?? '' + if (breakGlass !== '') { + if (breakGlass !== `${BREAK_GLASS_CONFIRMATION} ${cellId}`) { + throw new Error(`break glass requires --break-glass "${BREAK_GLASS_CONFIRMATION} ${cellId}"`) + } + if (mode !== 'write' || changes.admitMode !== null || Object.keys(changes).length !== 1) { + throw new Error('break glass only writes admitMode=default (db), on its own') + } + } + const directorOrigin = values['director-origin'] ?? '' + if (changes.admitMode !== undefined && !/^https:\/\/relay(-staging)?\.onorca\.dev$/.test(directorOrigin)) { + throw new Error('an admitMode change needs --director-origin, where Postgres records it') + } const projectId = values['project-id'] ?? '' if (!/^[a-z][a-z0-9-]{4,62}$/.test(projectId)) throw new Error('project id is invalid') const cellOrigin = values['cell-origin'] ?? '' @@ -49,14 +198,25 @@ export function parseCellFlagsRequest(values) { mode, cellId, cellOrigin, + directorOrigin, + breakGlass: breakGlass !== '', projectId, expectedGeneration, - object: { - v: 1, - cellId, - flags: { readinessLocal: values['readiness-local'] === 'true', ticketCheck } - } + changes + } +} + +// Read-modify-write: the current object's switches with only the named ones changed. +export function desiredObject(request, current) { + if (current !== null && (current?.v !== 1 || current?.cellId !== request.cellId)) { + throw new Error('the current object is not this cell\'s v1 switch object; fix it by hand') + } + const flags = { ...(current?.flags ?? {}) } + for (const [key, value] of Object.entries(request.changes)) { + if (value === null) delete flags[key] + else flags[key] = value } + return { v: 1, cellId: request.cellId, flags } } function objectUrl(request, suffix = '') { @@ -101,7 +261,7 @@ async function readCurrentObject(fetchImpl, request, accessToken) { return { generation: String(generation), object: await media.json().catch(() => null) } } -async function writeObject(fetchImpl, request, accessToken, audit) { +async function writeObject(fetchImpl, request, object, accessToken, audit) { const boundary = `relay-cell-flags-${Date.now()}` const metadata = { name: `cells/${request.cellId}.json`, @@ -118,7 +278,7 @@ async function writeObject(fetchImpl, request, accessToken, audit) { `--${boundary}`, 'Content-Type: application/json', '', - JSON.stringify(request.object), + JSON.stringify(object), `--${boundary}--`, '' ].join('\r\n') @@ -134,21 +294,119 @@ async function writeObject(fetchImpl, request, accessToken, audit) { body } ) + // A 4xx means nothing was written; a 5xx or a lost reply may have been. if (response.status === 412) { await response.body?.cancel().catch(() => undefined) - throw new Error('the object changed since expected-generation; re-read and retry') + throw Object.assign(new Error('the object changed since expected-generation; re-read and retry'), { + notWritten: true + }) + } + if (!response.ok) { + throw Object.assign(new Error(`object write returned ${response.status}`), { + notWritten: response.status < 500 + }) } - if (!response.ok) throw new Error(`object write returned ${response.status}`) return String((await response.json()).generation) } -const sameFlags = (left, right) => - left?.readinessLocal === right.readinessLocal && left?.ticketCheck === right.ticketCheck +// The census's record (relay_cell_admit_modes, through a director). Without `admitMode` it only +// reads. Read back on every write: sweeps act on this, not on the cell's switch file. +async function directorAdmitMode(fetchImpl, request, idToken, wait, admitMode) { + const response = await fetchAdminOnceMore( + fetchImpl, + `${request.directorOrigin}/v1/admin/cell-admit-mode`, + { + method: 'POST', + headers: { authorization: `Bearer ${idToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1, cellId: request.cellId, ...(admitMode ? { admitMode } : {}) }) + }, + { wait, timeoutMs: 10_000, retryDelayMs: 1_000 } + ) + const body = await response.json().catch(() => null) + if (!response.ok) throw new Error(`director refused admitMode: ${response.status} ${body?.error ?? ''}`) + if (admitMode && body?.admitMode !== admitMode) { + throw new Error(`director recorded admitMode ${body?.admitMode}, not ${admitMode}`) + } + return body +} + +// Every known key, not just the ones this run changed: a cell that kept an old value of any +// switch has not applied this object. A key with no fixed default (the cell reports its own +// value) is compared only when the object names it. +export function sameFlags(applied, desired) { + const expected = effectiveFlags(desired) + const actual = effectiveFlags(applied) + return Object.entries(CELL_FLAG_SPECS).every( + ([key, spec]) => + (!('default' in spec) && desired?.[key] === undefined) || actual[key] === expected[key] + ) +} + +// Sweeps resume only once every control the cell admitted from memory holds a lease again. +async function waitForLeasedControls(fetchImpl, request, idToken, { now, sleep, log }) { + let waitMs + let deadline = now() + REREGISTER_WAIT_MIN_MS + while (now() < deadline) { + const runtime = await readRuntime(fetchImpl, request, idToken, sleep).catch(() => null) + // An image without the field never admitted from memory. + if (runtime && (runtime.admitModeEffective ?? 'db') === 'db') return + if (runtime && waitMs === undefined) { + waitMs = reregisterWaitMs(runtime) + deadline = now() + waitMs + log(JSON.stringify({ event: 'orca_relay_cell_reregistration_wait', cellId: request.cellId, waitMs })) + } + await sleep(5_000) + } + log(JSON.stringify({ event: 'orca_relay_cell_reregistration_still_running', cellId: request.cellId })) + throw new Error( + `cell still re-registering after ${waitMs ?? REREGISTER_WAIT_MIN_MS} ms. Its connections are safe and Postgres ` + + 'keeps reserve, so sweeps stay off the cell. Run again with --set admitMode=db, which records db once ' + + 'runtime-status reports admitModeEffective=db; to speed it up, name both in one write: ' + + '--set admitMode=db,reregisterInFlight=N' + ) +} + +// No cell read-back and no wait for its controls: the cell is not answering. The file says db +// so a cell that comes back boots in db, and Postgres says db so the sweeps re-place its hosts. +async function breakGlassToDatabase(request, dependencies, { fetchImpl, log, sleep }) { + const { accessToken, idToken, audit } = dependencies + // Only for a dead cell: one that answers, or still heartbeats, takes the ordinary flip back. + const answered = await fetchImpl(`${request.cellOrigin}/v1/admin/runtime-status`, { + method: 'POST', + headers: { authorization: `Bearer ${idToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1 }), + signal: AbortSignal.timeout(10_000) + }) + .then(async (response) => { + await response.body?.cancel().catch(() => undefined) + return response.ok + }) + .catch(() => false) + if (answered) throw new Error('the cell answers runtime-status: flip it back with --set admitMode=db, not break glass') + const recorded = await directorAdmitMode(fetchImpl, request, idToken, sleep) + if (recorded?.heartbeatFresh !== false) { + throw new Error( + `the director ${recorded?.heartbeatFresh ? 'still sees heartbeats from' : 'cannot say whether it hears'} ` + + 'the cell: break glass is only for a cell that has stopped' + ) + } + const current = await readCurrentObject(fetchImpl, request, accessToken) + if (current.generation !== request.expectedGeneration) { + throw new Error(`current generation is ${current.generation}, not expected ${request.expectedGeneration}`) + } + const object = desiredObject(request, current.object) + const generation = await writeObject(fetchImpl, request, object, accessToken, audit) + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + const result = { event: 'orca_relay_cell_admit_mode_break_glass', cellId: request.cellId, generation, audit } + log(JSON.stringify(result)) + return { ...result, written: true } +} export async function operateCellFlags(request, dependencies) { const { fetchImpl = fetch, accessToken, idToken, audit, log = console.log } = dependencies const now = dependencies.now ?? Date.now const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) + if (request.breakGlass) return await breakGlassToDatabase(request, dependencies, { fetchImpl, log, sleep }) const runtime = await readRuntime(fetchImpl, request, idToken, sleep) // Proves the origin is this cell, and that its image reads switch files at all. if (runtime.role !== 'cell' || runtime.cellId !== request.cellId) { @@ -156,6 +414,20 @@ export async function operateCellFlags(request, dependencies) { } if (!runtime.flagsApplied) throw new Error('cell image has no flag channel (no flagsApplied)') const current = await readCurrentObject(fetchImpl, request, accessToken) + const object = desiredObject(request, current.object) + assertSupportedObject(object.flags, runtime) + // A tripped dead-man: any new generation that keeps reserve re-arms it, so the cell trips + // again and its re-registration restarts. Only a write that also says db is safe. + if ( + runtime.flagsApplied.flags?.admitMode === 'reserve' && + runtime.admitModeEffective === 'db' && + (object.flags.admitMode ?? 'db') === 'reserve' + ) { + throw new Error( + 'the cell tripped its dead-man (reserve configured, db effective); a write that keeps reserve re-arms it. ' + + 'Name admitMode=db in the same write, e.g. --set admitMode=db,reregisterInFlight=N' + ) + } const plan = { event: 'orca_relay_cell_flags_plan', mode: request.mode, @@ -163,7 +435,8 @@ export async function operateCellFlags(request, dependencies) { currentGeneration: current.generation, currentObject: current.object, appliedBefore: runtime.flagsApplied, - desired: request.object + changes: request.changes, + desired: object } // Before the generation check, so a dry run always shows the generation to pass next. log(JSON.stringify(plan)) @@ -172,8 +445,53 @@ export async function operateCellFlags(request, dependencies) { `current generation is ${current.generation}, not expected ${request.expectedGeneration}` ) } + // The cell applies every readable write; a generation it did not apply was voided there. A + // write of an object this image accepts whole (checked above) repairs it. + if (current.generation !== '0' && String(runtime.flagsApplied.generation) !== current.generation) { + log( + JSON.stringify({ + event: 'orca_relay_cell_flags_repairing_unapplied_object', + cellId: request.cellId, + appliedGeneration: runtime.flagsApplied.generation, + currentGeneration: current.generation + }) + ) + } + const admitModeChange = + request.changes.admitMode === undefined ? undefined : (request.changes.admitMode ?? 'db') + if (admitModeChange !== undefined) { + const recorded = await directorAdmitMode(fetchImpl, request, idToken, sleep) + log(JSON.stringify({ event: 'orca_relay_cell_admit_mode_recorded_before', ...recorded })) + } if (request.mode === 'dry-run') return { ...plan, written: false } - const generation = await writeObject(fetchImpl, request, accessToken, audit) + // Sweeps skip the cell before it starts admitting from memory, never after. + let generation + if (admitModeChange === 'reserve') { + const before = await directorAdmitMode(fetchImpl, request, idToken, sleep) + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'reserve') + // Every director's cached set expires (5 s) before the cell starts admitting from memory; + // the second write re-runs the open-flow check over anything started meanwhile. + await sleep(RESERVE_MODE_CACHE_WAIT_MS) + try { + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'reserve') + } catch (error) { + // A drain, migration or rehome opened during the wait: Postgres goes back as it was. + if (before?.admitMode === 'db') await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + throw error + } + try { + generation = await writeObject(fetchImpl, request, object, accessToken, audit) + } catch (error) { + // The file was refused outright (412, 4xx): put Postgres back as it was. After a 5xx the + // object may be written, so Postgres keeps reserve (sweeps stay off: the safe side). + if (error?.notWritten && before?.admitMode === 'db') { + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + } + throw error + } + } else { + generation = await writeObject(fetchImpl, request, object, accessToken, audit) + } log(JSON.stringify({ event: 'orca_relay_cell_flags_written', cellId: request.cellId, generation })) const deadline = now() + READ_BACK_TIMEOUT_MS let last = runtime.flagsApplied @@ -185,9 +503,22 @@ export async function operateCellFlags(request, dependencies) { // The object is written; a failed poll only delays the read-back until the deadline. continue } - if (String(last?.generation) === generation && sameFlags(last.flags, request.object.flags)) { + if (String(last?.generation) !== generation) continue + // The image predates a switch this object names: it applied the rest and dropped that one. + if (Array.isArray(last.ignoredKeys) && last.ignoredKeys.length > 0) { + throw new Error( + `cell applied generation ${generation} but ignores ${last.ignoredKeys.join(', ')}; ` + + 'its image predates them, so flip the object back' + ) + } + if (sameFlags(last.flags, object.flags)) { const result = { ...plan, written: true, generation, applied: last } log(JSON.stringify({ ...result, event: 'orca_relay_cell_flags_applied_read_back' })) + if (admitModeChange === 'db') { + await waitForLeasedControls(fetchImpl, request, idToken, { now, sleep, log }) + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + log(JSON.stringify({ event: 'orca_relay_cell_admit_mode_recorded', cellId: request.cellId, admitMode: 'db' })) + } return result } } @@ -205,10 +536,12 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { 'cell-id', 'cell-origin', 'project-id', - 'readiness-local', - 'ticket-check', + 'set', 'expected-generation', - 'confirmation' + 'confirmation', + 'confirm-reserve', + 'break-glass', + 'director-origin' ].map((name) => [name, { type: 'string' }]) ) }) diff --git a/cloud/dev/scripts/operate-relay-cell-flags.test.mjs b/cloud/dev/scripts/operate-relay-cell-flags.test.mjs index 23834755e336..3a5b565feff9 100644 --- a/cloud/dev/scripts/operate-relay-cell-flags.test.mjs +++ b/cloud/dev/scripts/operate-relay-cell-flags.test.mjs @@ -1,36 +1,104 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { + assertSupportedObject, + desiredObject, operateCellFlags, parseCellFlagsRequest, + parseFlagChanges, READ_BACK_TIMEOUT_MS, + REREGISTER_WAIT_MAX_MS, + REREGISTER_WAIT_MIN_MS, + reregisterLanes, + reregisterWaitMs, WRITE_CONFIRMATION } from './operate-relay-cell-flags.mjs' const CELL = 'production-gce-c26' +const RESERVE = { 'director-origin': 'https://relay.onorca.dev', 'confirm-reserve': `RESERVE ${CELL}` } const values = (overrides = {}) => ({ mode: 'write', 'cell-id': CELL, 'cell-origin': 'https://c26.relay.onorca.dev', 'project-id': 'onorca-cloud', - 'readiness-local': 'true', - 'ticket-check': 'off', + set: 'readinessLocal=true', 'expected-generation': '0', confirmation: `${WRITE_CONFIRMATION} ${CELL}`, ...overrides }) // A cell whose runtime status applies whatever was written, after `applyAfterPolls` polls. -function fakeGoogleAndCell({ stored = null, applyAfterPolls = 2, role = 'cell', cellId = CELL } = {}) { - const state = { stored, writes: [], polls: 0, applied: { generation: 0, flags: { readinessLocal: false, ticketCheck: 'off' } } } +const SUPPORTED = { + readinessLocal: { type: 'boolean' }, + ticketCheck: { type: 'enum', values: ['off', 'shadow', 'enforce'] }, + admitMode: { type: 'enum', values: ['db', 'reserve'] }, + intakePerSec: { type: 'number', min: 0, max: 1_000 }, + reserveDryRun: { type: 'boolean' }, + rejectionFence: { type: 'boolean' }, + readTimeoutMarginMs: { type: 'number', min: 1_000, max: 60_000, integer: true }, + reregisterInFlight: { type: 'number', min: 1, max: 16, integer: true } +} + +function fakeGoogleAndCell({ + stored = null, + applyAfterPolls = 2, + role = 'cell', + cellId = CELL, + supportedFlags = SUPPORTED, + // Polls that still report admitModeEffective=reserve after a flip back. + reregisteringPolls = 0, + controls, + // Forces admitModeEffective, as a tripped dead-man reports it. + effectiveOverride, + heartbeatFresh = false, + // The director refuses this numbered reserve post (1-based) with this error. + refuseReservePost +} = {}) { + const state = { + stored, + writes: [], + polls: 0, + // A cell that has applied whatever object is already there. + applied: stored + ? { generation: Number(stored.generation), flags: stored.object.flags } + : { generation: 0, flags: { readinessLocal: false, ticketCheck: 'off' } }, + directorAdmitMode: 'db', + reservePosts: 0, + order: [], + reregisteringPolls + } const fetchImpl = async (url, init = {}) => { const target = new URL(url) + if (target.pathname === '/v1/admin/cell-admit-mode') { + const body = JSON.parse(String(init.body)) + if (body.admitMode === 'reserve') { + state.reservePosts += 1 + if (refuseReservePost?.post === state.reservePosts) { + return Response.json({ error: refuseReservePost.error }, { status: 409 }) + } + } + if (body.admitMode) { + state.directorAdmitMode = body.admitMode + state.order.push(`director:${body.admitMode}`) + } + return Response.json({ v: 1, cellId: body.cellId, admitMode: state.directorAdmitMode, updatedAt: 1, heartbeatFresh }) + } if (target.pathname === '/v1/admin/runtime-status') { state.polls += 1 if (state.stored && state.polls > applyAfterPolls) { state.applied = { generation: Number(state.stored.generation), flags: state.stored.object.flags } } - return Response.json({ v: 1, role, cellId, flagsApplied: state.applied }) + const reserveApplied = state.applied.flags.admitMode === 'reserve' + const effective = reserveApplied || (state.writes.length > 0 && state.reregisteringPolls-- > 0) ? 'reserve' : 'db' + return Response.json({ + v: 1, + role, + cellId, + flagsApplied: state.applied, + admitModeEffective: effectiveOverride ?? effective, + ...(controls === undefined ? {} : { runtime: { controls } }), + ...(supportedFlags ? { supportedFlags } : {}) + }) } if (target.pathname.startsWith('/upload/')) { const expected = target.searchParams.get('ifGenerationMatch') @@ -41,6 +109,7 @@ function fakeGoogleAndCell({ stored = null, applyAfterPolls = 2, role = 'cell', const generation = String(Number(state.stored?.generation ?? 1000) + 1) state.stored = { generation, object } state.writes.push({ generation, object, metadata }) + state.order.push(`file:${object.flags.admitMode ?? 'db'}`) return Response.json({ generation }) } if (!state.stored) return new Response(null, { status: 404 }) @@ -73,7 +142,11 @@ const run = (request, fake, extra = {}) => { test('refuses malformed requests before touching anything', () => { assert.throws(() => parseCellFlagsRequest(values({ mode: 'apply' })), /mode/) assert.throws(() => parseCellFlagsRequest(values({ 'cell-id': 'all' })), /cell id/) - assert.throws(() => parseCellFlagsRequest(values({ 'ticket-check': 'enforce' })), /ticket-check/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'ticketCheck=always' })), /ticketCheck/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'placerMode=on' })), /unknown switch/) + assert.throws(() => parseCellFlagsRequest(values({ set: '' })), /names no switch/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'admitMode=db,admitMode=reserve' })), /twice/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'readTimeoutMarginMs=500' })), /cannot be/) assert.throws(() => parseCellFlagsRequest(values({ 'expected-generation': '' })), /expected/) assert.throws(() => parseCellFlagsRequest(values({ confirmation: WRITE_CONFIRMATION })), /confirmation/) assert.throws( @@ -92,7 +165,7 @@ test('a dry run reports the change and writes nothing', async () => { assert.equal(outcome.written, false) assert.equal(fake.state.writes.length, 0) assert.equal(lines[0].event, 'orca_relay_cell_flags_plan') - assert.deepEqual(lines[0].desired.flags, { readinessLocal: true, ticketCheck: 'off' }) + assert.deepEqual(lines[0].desired.flags, { readinessLocal: true }) }) test('writes with compare-and-swap, records who wrote it, and waits for the cell to apply it', async () => { @@ -101,12 +174,12 @@ test('writes with compare-and-swap, records who wrote it, and waits for the cell const outcome = await result assert.equal(outcome.written, true) assert.equal(outcome.generation, '1001') - assert.deepEqual(outcome.applied, { generation: 1001, flags: { readinessLocal: true, ticketCheck: 'off' } }) + assert.deepEqual(outcome.applied, { generation: 1001, flags: { readinessLocal: true } }) assert.deepEqual(fake.state.writes[0].metadata.metadata, { writtenBy: 'octocat', runId: '42', runAttempt: '1' }) assert.deepEqual(fake.state.writes[0].object, { v: 1, cellId: CELL, - flags: { readinessLocal: true, ticketCheck: 'off' } + flags: { readinessLocal: true } }) assert.deepEqual(lines.map((line) => line.event), [ 'orca_relay_cell_flags_plan', @@ -115,7 +188,7 @@ test('writes with compare-and-swap, records who wrote it, and waits for the cell ]) // Flip back: the next write must name the generation just written. const back = run( - parseCellFlagsRequest(values({ 'readiness-local': 'false', 'expected-generation': '1001' })), + parseCellFlagsRequest(values({ set: 'readinessLocal=false', 'expected-generation': '1001' })), fake ) assert.equal((await back.result).generation, '1002') @@ -182,3 +255,269 @@ test('fails loudly when the cell does not apply the write within the read-back w await assert.rejects(run(parseCellFlagsRequest(values()), fake).result, /did not apply generation 1001/) assert.equal(fake.state.writes.length, 1) }) + +test('changes only the named switches and keeps every other key the object already has', () => { + const current = { + v: 1, + cellId: CELL, + flags: { readinessLocal: true, ticketCheck: 'shadow', readTimeoutMarginMs: 5_000 } + } + const request = parseCellFlagsRequest( + values({ set: 'admitMode=reserve, intakePerSec=40, readTimeoutMarginMs=default', ...RESERVE }) + ) + assert.deepEqual(desiredObject(request, current), { + v: 1, + cellId: CELL, + flags: { readinessLocal: true, ticketCheck: 'shadow', admitMode: 'reserve', intakePerSec: 40 } + }) + assert.deepEqual(desiredObject(request, null).flags, { admitMode: 'reserve', intakePerSec: 40 }) + assert.throws(() => desiredObject(request, { v: 1, cellId: 'production-gce-c27', flags: {} }), /by hand/) + assert.deepEqual(parseFlagChanges('rejectionFence=false'), { rejectionFence: false }) + assert.deepEqual(parseFlagChanges('reregisterInFlight=8'), { reregisterInFlight: 8 }) + assert.throws(() => parseFlagChanges('reregisterInFlight=32'), /cannot be/) +}) + +test('a write keeps the switches it did not name, end to end', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { ticketCheck: 'shadow' } } } + }) + const outcome = await run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), fake).result + assert.equal(outcome.written, true) + assert.deepEqual(fake.state.writes[0].object.flags, { ticketCheck: 'shadow', readinessLocal: true }) +}) + +test('the read-back compares every switch, not only the one this run changed', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { ticketCheck: 'shadow' } } } + }) + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + const response = await inner(url, init) + if (new URL(url).pathname !== '/v1/admin/runtime-status') return response + const body = await response.json() + // The cell reports the new generation but still runs the old ticketCheck. + if (body.flagsApplied.generation === 8) body.flagsApplied.flags = { readinessLocal: true } + return Response.json(body) + } + await assert.rejects( + run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), fake).result, + /did not apply generation 8/ + ) +}) + +test('a switch the cell reports on its own is compared only when the object names it', async () => { + const fake = fakeGoogleAndCell() + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + const response = await inner(url, init) + if (new URL(url).pathname !== '/v1/admin/runtime-status') return response + const body = await response.json() + body.flagsApplied.flags = { ...body.flagsApplied.flags, intakePerSec: 50, readTimeoutMarginMs: 10_000 } + return Response.json(body) + } + assert.equal((await run(parseCellFlagsRequest(values()), fake).result).written, true) +}) + +test('fails the read-back when the cell ignores a switch the object names', async () => { + const fake = fakeGoogleAndCell() + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + const response = await inner(url, init) + if (new URL(url).pathname !== '/v1/admin/runtime-status') return response + const body = await response.json() + if (body.flagsApplied.generation === 1001) body.flagsApplied.ignoredKeys = ['admitMode'] + return Response.json(body) + } + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'admitMode=reserve', ...RESERVE })), fake).result, + /ignores admitMode/ + ) +}) + +test('refuses a switch the cell image does not support, before writing anything', async () => { + const old = fakeGoogleAndCell({ supportedFlags: null }) + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'ticketCheck=enforce' })), old).result, + /does not support ticketCheck="enforce"/ + ) + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'admitMode=reserve', ...RESERVE })), old).result, + /does not support admitMode/ + ) + assert.equal(old.state.writes.length, 0) + // An image from before supportedFlags still takes the two switches it always had. + assert.equal((await run(parseCellFlagsRequest(values()), old).result).written, true) + assert.throws( + () => assertSupportedObject({ readTimeoutMarginMs: 1_500.5 }, { supportedFlags: SUPPORTED }), + /readTimeoutMarginMs/ + ) + assertSupportedObject({ ticketCheck: 'enforce' }, { supportedFlags: SUPPORTED }) +}) + +test('refuses to carry a bad value already in the file', async () => { + const bad = fakeGoogleAndCell({ + supportedFlags: null, + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { ticketCheck: 'enforce' } } } + }) + // The old image voided generation 7 and still runs generation 0. + bad.state.applied = { generation: 0, flags: { readinessLocal: false, ticketCheck: 'off' } } + await assert.rejects( + run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), bad).result, + /does not support ticketCheck="enforce"/ + ) + assert.equal(bad.state.writes.length, 0) +}) + +const DIRECTOR = { 'director-origin': RESERVE['director-origin'] } + +test('admitMode=reserve needs a typed confirmation, and Postgres records reserve before the file', async () => { + assert.throws( + () => parseCellFlagsRequest(values({ ...DIRECTOR, set: 'admitMode=reserve' })), + /--confirm-reserve "RESERVE production-gce-c26"/ + ) + assert.throws(() => parseCellFlagsRequest(values({ set: 'admitMode=db' })), /--director-origin/) + const fake = fakeGoogleAndCell() + const request = parseCellFlagsRequest( + values({ ...DIRECTOR, set: 'admitMode=reserve', 'confirm-reserve': `RESERVE ${CELL}` }) + ) + assert.equal((await run(request, fake).result).written, true) + // Recorded, re-checked after every director's cache has expired, then the file. + assert.deepEqual(fake.state.order, ['director:reserve', 'director:reserve', 'file:reserve']) +}) + +test('a refused file write after Postgres says reserve puts Postgres back to db', async () => { + const fake = fakeGoogleAndCell() + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + if (new URL(url).pathname.startsWith('/upload/')) return new Response(null, { status: 403 }) + return await inner(url, init) + } + const request = parseCellFlagsRequest(values({ set: 'admitMode=reserve', ...RESERVE })) + await assert.rejects(run(request, fake).result, /returned 403/) + assert.deepEqual(fake.state.order, ['director:reserve', 'director:reserve', 'director:db']) +}) + +test('repairs an object the cell did not apply when the whole new object is valid', async () => { + const voided = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { readinessLocal: true } } } + }) + voided.state.applied = { generation: 3, flags: { readinessLocal: false } } + const { result, lines } = run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), voided) + assert.equal((await result).written, true) + assert.ok(lines.some((line) => line.event === 'orca_relay_cell_flags_repairing_unapplied_object')) +}) + +test('a flip back records db in Postgres only after the cell has leased every control', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } }, + reregisteringPolls: 3 + }) + fake.state.directorAdmitMode = 'reserve' + const request = parseCellFlagsRequest(values({ ...DIRECTOR, set: 'admitMode=default', 'expected-generation': '7' })) + assert.equal((await run(request, fake).result).written, true) + assert.deepEqual(fake.state.order, ['file:db', 'director:db']) + assert.equal(fake.state.reregisteringPolls, -1) +}) + +test('the flip-back wait scales with the control count and the switch-file pace', () => { + assert.equal(reregisterWaitMs({ runtime: { controls: 100 } }), REREGISTER_WAIT_MIN_MS) + // 1,500 controls at the default 3 lanes: 1,667 s. + assert.equal(reregisterWaitMs({ runtime: { controls: 1_500 } }), 1_666_667) + assert.equal( + reregisterWaitMs({ runtime: { controls: 1_500 }, flagsApplied: { flags: { reregisterInFlight: 10 } } }), + // The cell caps 10 at its US pool of 10 less two. + 625_000 + ) + assert.equal(reregisterWaitMs({ runtime: { controls: 3_000 } }), REREGISTER_WAIT_MAX_MS) +}) + +test('the wait counts the lanes the cell actually uses', () => { + assert.equal(reregisterLanes({ region: 'us-central1' }), 3) + assert.equal(reregisterLanes({ region: 'asia-east2' }), 5) + assert.equal(reregisterLanes({ region: 'us-central1', flagsApplied: { flags: { reregisterInFlight: 16 } } }), 8) + assert.equal(reregisterLanes({ region: 'asia-east2', flagsApplied: { flags: { reregisterInFlight: 16 } } }), 14) + // The pool the cell reports wins over the deployed-size table. + assert.equal(reregisterLanes({ region: 'us-central1', databasePoolMax: 22 }), 7) + assert.equal(reregisterLanes({ region: 'asia-east2', databasePoolMax: 12, flagsApplied: { flags: { reregisterInFlight: 16 } } }), 10) + assert.equal(reregisterLanes({ region: 'asia-east2', databasePoolMax: 'x' }), 5) +}) + +test('a tripped cell takes no write that keeps reserve, only one that also says db', async () => { + const stored = { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } } + const tripped = () => fakeGoogleAndCell({ stored, effectiveOverride: 'db' }) + for (const set of ['readinessLocal=true', 'reregisterInFlight=4']) { + const fake = tripped() + const request = parseCellFlagsRequest(values({ set, 'expected-generation': '7' })) + await assert.rejects(run(request, fake).result, /tripped its dead-man[\s\S]*admitMode=db,reregisterInFlight=N/) + assert.deepEqual(fake.state.writes, []) + } + const fake = tripped() + fake.state.directorAdmitMode = 'reserve' + const request = parseCellFlagsRequest( + values({ ...DIRECTOR, set: 'admitMode=db,reregisterInFlight=4', 'expected-generation': '7' }) + ) + assert.equal((await run(request, fake).result).written, true) + assert.deepEqual(fake.state.writes[0].object.flags, { admitMode: 'db', reregisterInFlight: 4 }) +}) + +test('a migration opened during the cache wait puts Postgres back to db', async () => { + const fake = fakeGoogleAndCell({ refuseReservePost: { post: 2, error: 'cell_has_open_migration' } }) + const request = parseCellFlagsRequest(values({ ...RESERVE, set: 'admitMode=reserve' })) + await assert.rejects(run(request, fake).result, /cell_has_open_migration/) + assert.deepEqual(fake.state.order, ['director:reserve', 'director:db']) + assert.deepEqual(fake.state.writes, []) +}) + +test('break glass refuses a cell that answers or that the director still hears', async () => { + const glass = { ...RESERVE, set: 'admitMode=default', 'expected-generation': '7', 'break-glass': `BREAK GLASS ${CELL}` } + const stored = { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } } + const answering = fakeGoogleAndCell({ stored }) + await assert.rejects(run(parseCellFlagsRequest(values(glass)), answering).result, /answers runtime-status/) + const heard = fakeGoogleAndCell({ stored, heartbeatFresh: true }) + const inner = heard.fetchImpl + heard.fetchImpl = async (url, init) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') throw new Error('connect refused') + return await inner(url, init) + } + await assert.rejects(run(parseCellFlagsRequest(values(glass)), heard).result, /still sees heartbeats/) + assert.deepEqual(heard.state.writes, []) + assert.deepEqual(answering.state.writes, []) +}) + +test('a flip back that outlasts its wait keeps Postgres on reserve and says how to finish', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } }, + reregisteringPolls: Number.POSITIVE_INFINITY, + controls: 1_500 + }) + fake.state.directorAdmitMode = 'reserve' + const request = parseCellFlagsRequest(values({ ...DIRECTOR, set: 'admitMode=default', 'expected-generation': '7' })) + const { lines, result } = run(request, fake) + await assert.rejects(result, /after 1666667 ms[\s\S]*Run again with --set admitMode=db[\s\S]*--set admitMode=db,reregisterInFlight=N/) + assert.deepEqual(fake.state.order, ['file:db']) + assert.equal(lines.find((line) => line.event === 'orca_relay_cell_reregistration_wait')?.waitMs, 1_666_667) +}) + +test('break glass records db for a cell that cannot answer, typed for that cell only', async () => { + const glass = { ...RESERVE, set: 'admitMode=default', 'expected-generation': '7' } + assert.throws(() => parseCellFlagsRequest(values({ ...glass, 'break-glass': 'BREAK GLASS production-gce-c27' })), /break glass requires/) + assert.throws( + () => parseCellFlagsRequest(values({ ...glass, set: 'admitMode=default,readinessLocal=true', 'break-glass': `BREAK GLASS ${CELL}` })), + /on its own/ + ) + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve', readinessLocal: true } } } + }) + fake.state.directorAdmitMode = 'reserve' + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') throw new Error('connect refused') + return await inner(url, init) + } + const { result, lines } = run(parseCellFlagsRequest(values({ ...glass, 'break-glass': `BREAK GLASS ${CELL}` })), fake) + assert.equal((await result).written, true) + assert.deepEqual(fake.state.writes[0].object.flags, { readinessLocal: true }) + assert.deepEqual(fake.state.order, ['file:db', 'director:db']) + assert.equal(lines.at(-1).event, 'orca_relay_cell_admit_mode_break_glass') +}) + diff --git a/cloud/dev/scripts/power-staging-relay.mjs b/cloud/dev/scripts/power-staging-relay.mjs index 557042941926..e68c01eadb75 100644 --- a/cloud/dev/scripts/power-staging-relay.mjs +++ b/cloud/dev/scripts/power-staging-relay.mjs @@ -329,6 +329,57 @@ function assertQuiescent(status) { } } +// `undefined` when the director predates the step-5 reserve census (its router 404s the route). +async function reserveCells(deps, cells) { + const token = deps.adminToken() + const reserve = [] + for (const cell of cells) { + const response = await deps.fetch(`${DIRECTOR_ORIGIN}/v1/admin/cell-admit-mode`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1, cellId: cell.cellId }), + signal: AbortSignal.timeout(30_000) + }) + const result = await response.json().catch(() => null) + if (response.status === 404 && result === null) return undefined + if (!response.ok || result?.v !== 1 || !['db', 'reserve'].includes(result.admitMode)) { + throw new Error(`${cell.cellId} admit mode read failed: ${result?.error ?? response.status}`) + } + if (result.admitMode === 'reserve') reserve.push(cell.cellId) + } + return reserve +} + +// Under the monotonic selector, admission belongs to the selector, and the selector-era wake +// restores it; so this sleep proves the fleet empty and never writes a cell's admission. +async function sleepUnderSelector(deps, cells, adminPost, selector) { + const reserve = await reserveCells(deps, cells) + if (reserve?.length) { + throw new Error(`staging sleep refuses reserve-mode cells: ${reserve.join(', ')}`) + } + for (const service of CLOUD_RUN_SERVICES) await ensureCloudRunScaleToZero(deps, service) + const final = await Promise.all(cells.map((cell) => cellStatus(adminPost, cell))) + for (const status of final) assertQuiescent(status) + const after = await inspectAdmissionSelector( + async (path, body) => await adminPost(DIRECTOR_ORIGIN, path, body) + ) + if (after.selector.generation !== selector.generation) { + throw new Error('admission selector changed during staging sleep') + } + // The flag workflow runs outside the staging lock, so a reserve flip can land mid-sleep. + const lateReserve = await reserveCells(deps, cells) + if (lateReserve?.length) { + throw new Error(`staging sleep refuses reserve-mode cells: ${lateReserve.join(', ')}`) + } + await Promise.all(cells.map((cell) => setMigSize(deps, cell, 0))) + await ensureSqlPolicy(deps, 'NEVER') + deps.emit({ + event: 'staging_relay_slept', + stoppedCells: cells.map((cell) => cell.cellId), + selectorGeneration: selector.generation + }) +} + async function setCellState(adminPost, cell, enabled) { await adminPost(DIRECTOR_ORIGIN, '/v1/admin/cell-state', { v: 1, @@ -376,9 +427,8 @@ async function sleepStaging(deps, cells) { async (path, body) => await adminPost(DIRECTOR_ORIGIN, path, body) ) if (selector.selector.generation > 0) { - throw new Error( - 'staging sleep cannot reverse the monotonic admission selector; keep staging awake' - ) + await sleepUnderSelector(deps, cells, adminPost, selector.selector) + return } const previouslyEnabled = new Set(initial.filter((status) => status.enabled).map((status) => status.cellId)) diff --git a/cloud/dev/scripts/power-staging-relay.test.mjs b/cloud/dev/scripts/power-staging-relay.test.mjs index 21c7794d48e5..847e1de8744a 100644 --- a/cloud/dev/scripts/power-staging-relay.test.mjs +++ b/cloud/dev/scripts/power-staging-relay.test.mjs @@ -65,7 +65,8 @@ function harness({ sqlPolicy = 'ALWAYS', migSize = 1, observedRequests = 0, - selectorGeneration = 0 + selectorGeneration = 0, + admitModes } = {}) { const cells = new Map( ['staging-gce-c1', 'staging-gce-c2', 'staging-gce-c3', 'staging-gce-c4'].map((cellId, index) => [ @@ -88,6 +89,7 @@ function harness({ ]) const commands = [] const events = [] + const posts = [] let activationPolicy = sqlPolicy let clock = 0 @@ -154,6 +156,12 @@ function harness({ const parsed = new URL(url) if (!options.method) return response({ ok: true }) const body = JSON.parse(options.body) + posts.push(parsed.pathname) + if (parsed.pathname === '/v1/admin/cell-admit-mode') { + // A pre-census director's router answers an unknown route with a plain-text 404. + if (!admitModes) return new Response('404 Not Found', { status: 404 }) + return response({ v: 1, cellId: body.cellId, admitMode: admitModes[body.cellId] ?? 'db' }) + } if (parsed.pathname === '/v1/admin/cell-status') { const state = cells.get(body.cellId) const index = [...cells.keys()].indexOf(body.cellId) @@ -225,6 +233,8 @@ function harness({ cells, commands, events, + posts, + admitModes, deps: { command, commandJson, @@ -292,13 +302,71 @@ test('sleeps only after disabling admission and proving zero active work', async ) }) -test('refuses staging sleep after the monotonic selector boundary', async () => { - const testHarness = harness({ selectorGeneration: 1 }) +test('sleeps under the selector without writing any cell admission', async () => { + for (const admitModes of [undefined, {}]) { + const testHarness = harness({ selectorGeneration: 3, admitModes }) + const before = [...testHarness.cells.values()].map((cell) => cell.enabled) + await runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), testHarness.deps) + + assert.equal(testHarness.sqlPolicy(), 'NEVER') + assert.deepEqual([...testHarness.cells.values()].map((cell) => cell.targetSize), [0, 0, 0, 0]) + assert.deepEqual([...testHarness.cells.values()].map((cell) => cell.enabled), before) + assert.equal(testHarness.posts.includes('/v1/admin/cell-state'), false) + assert.equal(testHarness.posts.includes('/v1/admin/cell-admit-mode'), true) + assert.deepEqual(testHarness.events.at(-1), { + event: 'staging_relay_slept', + stoppedCells: ['staging-gce-c1', 'staging-gce-c2', 'staging-gce-c3', 'staging-gce-c4'], + selectorGeneration: 3 + }) + } +}) + +test('refuses a selector-era sleep while any cell is reserve or busy', async () => { + const reserve = harness({ selectorGeneration: 3, admitModes: { 'staging-gce-c2': 'reserve' } }) await assert.rejects( - runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), testHarness.deps), - /cannot reverse the monotonic admission selector/ + runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), reserve.deps), + /refuses reserve-mode cells: staging-gce-c2/ + ) + assert.equal(reserve.commands.length, 0) + + // A flip that lands after the first read, while Cloud Run scales down, still stops the resize. + const late = harness({ selectorGeneration: 3, admitModes: {} }) + const run = late.deps.command + late.deps.command = (args) => { + if (args[0] === 'run' && args[2] === 'update-traffic') late.admitModes['staging-gce-c4'] = 'reserve' + return run(args) + } + await assert.rejects( + runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), late.deps), + /refuses reserve-mode cells: staging-gce-c4/ + ) + assert.deepEqual([...late.cells.values()].map((cell) => cell.targetSize), [1, 1, 1, 1]) + assert.equal(late.sqlPolicy(), 'ALWAYS') + + const busy = harness({ selectorGeneration: 3, observedRequests: 1 }) + await assert.rejects( + runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), busy.deps), + /still has active Relay work/ ) + assert.equal(busy.commands.length, 0) + assert.deepEqual([...busy.cells.values()].map((cell) => cell.targetSize), [1, 1, 1, 1]) +}) + +test('a selector-era sleep then wake restores the selector membership', async () => { + const testHarness = harness({ selectorGeneration: 3 }) + const file = topologyFile() + await runStagingRelayPower(argumentConfig(file, 'sleep'), testHarness.deps) + for (const state of testHarness.cells.values()) state.observedRequests = 0 + await runStagingRelayPower(argumentConfig(file, 'wake'), testHarness.deps) + + assert.equal(testHarness.sqlPolicy(), 'ALWAYS') assert.deepEqual([...testHarness.cells.values()].map((cell) => cell.targetSize), [1, 1, 1, 1]) + assert.equal(testHarness.posts.includes('/v1/admin/cell-state'), false) + assert.deepEqual(testHarness.events.at(-1), { + event: 'staging_relay_woke', + runningCells: ['staging-gce-c1', 'staging-gce-c2', 'staging-gce-c3', 'staging-gce-c4'], + admissionCells: ['staging-gce-c3'] + }) }) test('refuses to terminate workers from an unknown partially asleep state', async () => { diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs index 796d0e9d91ad..b8e368f6e1b2 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs @@ -53,7 +53,9 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { try { service = run(gcloudArguments('services', input)) } catch (error) { - if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version, cohort_percent: '0' } + if (error?.code === 'NOT_FOUND') { + return { version: input.bootstrap_version, cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' } + } throw error } const serving = (service.status?.traffic ?? []).filter( @@ -77,12 +79,21 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { throw new Error('serving region correction cohort is invalid') } const cohort_percent = cohortSettings[0]?.value ?? '0' + // Set by the director deploy (placement) or by hand (shadow cells), never by Terraform: carried + // so an apply keeps them, and empty when the serving revision has none. + const reserve_placement = servingPlainSetting(revision, 'ORCA_RELAY_RESERVE_PLACEMENT', /^(?:off|dry-run|on)$/) + const shadow_seat_feed_cells = servingPlainSetting( + revision, + 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', + /^(?:all|[A-Za-z0-9_-]+(?:, ?[A-Za-z0-9_-]+)*)$/ + ) + const carried = { cohort_percent, reserve_placement, shadow_seat_feed_cells } const references = (revision.spec?.containers ?? []).flatMap((container) => (container.env ?? []).filter( (environment) => environment.name === 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' ) ) - if (references.length === 0) return { version: input.bootstrap_version, cohort_percent } + if (references.length === 0) return { version: input.bootstrap_version, ...carried } const reference = normalizeSecretReference(references[0]) if ( references.length !== 1 || @@ -91,7 +102,20 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { ) { throw new Error('serving regional placement secret reference is invalid') } - return { version: reference.version, cohort_percent } + return { version: reference.version, ...carried } +} + +// One plain value, or none; anything else (a secret, a duplicate, a malformed value) fails the +// plan rather than letting an apply drop or reset the setting. +function servingPlainSetting(revision, name, valid) { + const settings = (revision.spec?.containers ?? []).flatMap((container) => + (container.env ?? []).filter((environment) => environment.name === name) + ) + if (settings.length === 0) return '' + if (settings.length > 1 || typeof settings[0].value !== 'string' || !valid.test(settings[0].value)) { + throw new Error(`serving ${name} is invalid`) + } + return settings[0].value } // Why: the v2 API reports `valueSource.secretKeyRef.{secret,version}`, but diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs index 9c49d4127a5c..8fd0c3a551e2 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import test from 'node:test' +import { fileURLToPath } from 'node:url' import { classifyRelayServiceDescribeFailure, readRelayServingRegionalPlacementVersion @@ -67,7 +69,7 @@ test('reads the exact version from the sole traffic-serving revision', () => { } }) - assert.deepEqual(result, { version: '11', cohort_percent: '0' }) + assert.deepEqual(result, { version: '11', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) assert.equal(calls[1][3], 'relay-serving') }) @@ -78,7 +80,7 @@ test('reads the gcloud v1 secret reference shape by bare id and by full resource ]) { assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? serving() : v1Revision(name, '1') - }), { version: '1', cohort_percent: '0' }) + }), { version: '1', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) } }) @@ -100,12 +102,12 @@ test('falls back only when the service or setting is absent', () => { notFound.code = 'NOT_FOUND' assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: () => { throw notFound } - }), { version: '7', cohort_percent: '0' }) + }), { version: '7', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? { status: { traffic: [{ revisionName: 'relay-serving', percent: 100 }] } } : { spec: { containers: [{ env: [] }] } } - }), { version: '7', cohort_percent: '0' }) + }), { version: '7', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) }) test('classifies real absent-service stderr without weakening revision failures', () => { @@ -144,7 +146,7 @@ test('preserves the serving cohort including explicit disable across later Terra servingRevision.spec.containers[0].env.push({ name: 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT', value }) assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? serving() : servingRevision - }), { version: '11', cohort_percent: value }) + }), { version: '11', cohort_percent: value, reserve_placement: '', shadow_seat_feed_cells: '' }) } }) @@ -164,3 +166,44 @@ test('fails closed on malformed, secret-backed or duplicate cohorts rather than }), /cohort is invalid/) } }) + +test('carries the deploy-owned placement and shadow cells, so an apply keeps them', () => { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push( + { name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'on' }, + { name: 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', value: 'production-gce-c1,production-gce-c2' } + ) + assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), { + version: '11', + cohort_percent: '0', + reserve_placement: 'on', + shadow_seat_feed_cells: 'production-gce-c1,production-gce-c2' + }) +}) + +test('fails closed on a malformed, secret-backed or duplicate placement or shadow-cell setting', () => { + const cases = [ + [{ name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'yes' }], + [{ name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'on' }, { name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'off' }], + [{ name: 'ORCA_RELAY_RESERVE_PLACEMENT', valueFrom: { secretKeyRef: { name: 'x', key: '1' } } }], + [{ name: 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', value: 'c1;c2' }], + [{ name: 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', value: '' }] + ] + for (const settings of cases) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push(...settings) + assert.throws(() => readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), /is invalid/) + } +}) + +test('Terraform sets the carried settings only when the serving revision has them', () => { + const terraform = readFileSync(fileURLToPath(new URL('../../infra/terraform/relay.tf', import.meta.url)), 'utf8') + const block = /dynamic "env" \{([\s\S]*?)\n {6}\}/.exec(terraform)?.[1] ?? '' + assert.match(block, /ORCA_RELAY_RESERVE_PLACEMENT *= data\.external\.relay_serving_regional_placement_version\.result\.reserve_placement/) + assert.match(block, /ORCA_RELAY_SHADOW_SEAT_FEED_CELLS *= data\.external\.relay_serving_regional_placement_version\.result\.shadow_seat_feed_cells/) + assert.match(block, /if value != ""/) +}) diff --git a/cloud/dev/scripts/relay-control-flag-alerts.test.mjs b/cloud/dev/scripts/relay-control-flag-alerts.test.mjs new file mode 100644 index 000000000000..95604f29d6e5 --- /dev/null +++ b/cloud/dev/scripts/relay-control-flag-alerts.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +// Why: the metric keys on the event name and fields the switch-file reader writes. A rename on +// either side silences the alert without failing anything. + +const read = (relative) => readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8') +const terraform = read('../../infra/terraform/relay-observability.tf') +const channel = read('../../apps/relay/src/relay-control-flag-channel.ts') +const cellFlags = read('../../apps/relay/src/cell-flags.ts') + +const block = (kind, name) => { + const body = new RegExp(`resource "${kind}" "${name}" \\{([\\s\\S]*?)\\n\\}`).exec(terraform)?.[1] + assert.ok(body, `${kind}.${name} not found in relay-observability.tf`) + return body +} + +test('the metric counts the event and fields the reader writes', () => { + const unreadableLine = /event: 'orca_relay_control_flags_unreadable',\s*object: input\.objectName,\s*failure,/ + assert.match(channel, unreadableLine) + assert.match(cellFlags, /return `cells\/\$\{cellId\}\.json`/) + const metric = block('google_logging_metric', 'relay_control_flags_unreadable') + assert.ok(metric.includes('jsonPayload.event=\\"orca_relay_control_flags_unreadable\\"')) + assert.ok(metric.includes('REGEXP_EXTRACT(jsonPayload.object, \\"cells/([^.]+)\\")')) + assert.ok(metric.includes('failure = "EXTRACT(jsonPayload.failure)"')) +}) + +test('the alert fires on one line per cell and stays in the console', () => { + const alert = block('google_monitoring_alert_policy', 'relay_control_flags_unreadable') + // The reader logs once per change of cause, so a stuck cell writes one line; a higher bar misses it. + assert.match(alert, /threshold_value = 0\n/) + assert.match(alert, /notification_channels = \[\]/) + assert.ok(alert.includes('metric.type=\\"logging.googleapis.com/user/orca_relay_control_flags_unreadable\\"')) + assert.ok(alert.includes('group_by_fields = ["metric.label.\\"cell_id\\""]')) +}) diff --git a/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs b/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs new file mode 100644 index 000000000000..8dea82fe6e9e --- /dev/null +++ b/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments as parseBlueGreen } from './deploy-relay-blue-green.mjs' +import { parseArguments as parseGceCandidate } from './deploy-relay-gce-candidate.mjs' +import { RELAY_WORKFLOW_DIRECTORY, readRelayWorkflow } from './relay-repository.mjs' + +// Every workflow's real argv, literals as written: a flag combination the parser refuses +// (staging's lone --admin-audience once was one) fails here instead of on a release. + +const PROJECT = 'orca-test-project' +const DIGEST = `sha256:${'a'.repeat(64)}` + +// Shell-expanded values only; anything a workflow writes literally is parsed as written. +const SAMPLES = { + project: PROJECT, + image: `relay@${DIGEST}`, + 'predecessor-image-digest': DIGEST, + 'director-cells-json': JSON.stringify([ + { id: 'cell-a', url: 'https://cell-a.example.test', region: 'us-central1', capacityRequests: 10, initiallyEnabled: true } + ]), + 'regional-placement-secret-version': '3', + 'min-instances': '1', + 'max-instances': '5', + 'prune-revisions': 'false', + 'bootstrap-runtime-identity': 'false', + 'expected-rehome-generation': '4', + 'region-correction-cohort-percent': '10', + 'rehome-audience': 'https://relay.example.test/v1/admin/host-drain', + 'director-origin': 'https://relay.example.test', + 'admin-audience': 'https://relay.example.test/v1/admin/drain', + 'reserve-placement': 'preserve', + 'shadow-seat-feed-cells': 'preserve', + mode: 'audit' +} + +function sample(flag) { + if (flag in SAMPLES) return SAMPLES[flag] + if (flag.endsWith('service-account')) return `${flag.slice(0, 20).replace(/-$/, '')}@${PROJECT}.iam.gserviceaccount.com` + return `sample-${flag}` +} + +// Flags a workflow passes through a bash array (`"${name[@]}"`), from every `name=(...)` it +// assigns; parsing them all at once is the widest set that workflow can send. +function arrayArguments(source, name) { + const flags = [] + for (const match of source.matchAll(new RegExp(`\\b${name}=\\(([^)]*)\\)`, 'g'))) { + for (const entry of match[1].matchAll(/(--[a-z0-9-]+)\s+("[^"]*"|\S+)/g)) { + flags.push([entry[1], entry[2].replace(/^"(.*)"$/, '$1')]) + } + } + if (flags.length === 0) throw new Error(`no flags assigned to ${name}`) + return flags +} + +function invocations(source, script) { + const lines = source.split('\n') + const found = [] + for (let index = 0; index < lines.length; index++) { + if (!lines[index].includes(`dev/scripts/${script}`)) continue + const argv = [] + let line = lines[index] + while (line.trimEnd().endsWith('\\')) { + line = lines[++index] + const array = /^\s*"\$\{([a-z_]+)\[@\]\}"\s*\\?\s*$/.exec(line) + if (array) { + for (const [flag, value] of arrayArguments(source, array[1])) { + argv.push(flag, value.includes('$') ? sample(flag.slice(2)) : value) + } + continue + } + const match = /^\s*(--[a-z0-9-]+)\s+(.+?)\s*\\?\s*$/.exec(line) + if (!match) throw new Error(`unparsed ${script} argument line: ${line.trim()}`) + const [, flag, raw] = match + // A trailing `)"` closes the command substitution the invocation sits in. + const value = raw.replace(/\)"$/, '').replace(/^"(.*)"$/, '$1') + argv.push(flag, value.includes('$') ? sample(flag.slice(2)) : value) + } + found.push(argv) + } + return found +} + +const SCRIPTS = [ + ['deploy-relay-blue-green.mjs', parseBlueGreen], + ['deploy-relay-gce-candidate.mjs', parseGceCandidate] +] + +for (const [script, parse] of SCRIPTS) { + test(`every workflow's ${script} argv parses`, () => { + let count = 0 + for (const file of readdirSync(RELAY_WORKFLOW_DIRECTORY).filter((name) => name.endsWith('.yml'))) { + for (const argv of invocations(readFileSync(new URL(file, RELAY_WORKFLOW_DIRECTORY), 'utf8'), script)) { + count++ + assert.doesNotThrow(() => parse(argv), `${file}: ${argv.join(' ')}`) + } + } + assert.ok(count > 0, `no workflow runs ${script}`) + }) +} + +test('the production and staging director workflows are among the parsed', () => { + for (const name of ['deploy-relay-production-director.yml', 'deploy-relay-staging.yml']) { + assert.equal(invocations(readRelayWorkflow(name), 'deploy-relay-blue-green.mjs').length, 1, name) + } +}) diff --git a/cloud/dev/scripts/relay-director-deploy-plan.mjs b/cloud/dev/scripts/relay-director-deploy-plan.mjs index aaeb82bddc97..5f45de5afb56 100644 --- a/cloud/dev/scripts/relay-director-deploy-plan.mjs +++ b/cloud/dev/scripts/relay-director-deploy-plan.mjs @@ -211,6 +211,8 @@ export function directorDeployInputs({ imageDigest, predecessorDigest, rehomeGen 'image-digest': imageDigest, 'regional-placement-mode': 'preserve', 'region-correction-cohort-percent': 'preserve', + 'reserve-placement': 'preserve', + 'shadow-seat-feed-cells': 'preserve', 'prune-incompatible-revisions': 'false', 'expected-rehome-generation': String(rehomeGeneration), 'bootstrap-runtime-identity': 'false', diff --git a/cloud/dev/scripts/relay-load-control-peer.mjs b/cloud/dev/scripts/relay-load-control-peer.mjs index 26d0ca6a69b0..57b5da37506a 100644 --- a/cloud/dev/scripts/relay-load-control-peer.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.mjs @@ -188,7 +188,8 @@ export class RelayLoadControlPeer { this.index = index this.options = options this.observe = observe - this.keys = nacl.box.keyPair() + // A twin shares a host's keys, so it can seat the same relay host on a second cell. + this.keys = options.keys ?? nacl.box.keyPair() this.relayHostId = createHash('sha256') .update(this.keys.publicKey) .digest('base64url') @@ -210,7 +211,8 @@ export class RelayLoadControlPeer { this.spliceSequence = 0 } - connect() { + // `assignment`, when given, is used instead of asking the director. + connect(assignment) { if ( this.stopped || this.connecting || @@ -219,7 +221,7 @@ export class RelayLoadControlPeer { return Promise.resolve() } this.connecting = true - const operation = this.connectOnce() + const operation = this.connectOnce(assignment) this.inFlight.add(operation) const finish = () => { this.connecting = false @@ -233,12 +235,12 @@ export class RelayLoadControlPeer { return this.lastAssignment?.cellUrl } - async connectOnce() { + async connectOnce(givenAssignment) { let socket = null try { const relayToken = await this.relayToken() if (this.stopped) return - const assignment = await this.assignment(relayToken) + const assignment = givenAssignment ?? (await this.assignment(relayToken)) if (this.stopped) return this.lastAssignment = assignment socket = this.createSocket(assignment, relayToken) diff --git a/cloud/dev/scripts/relay-load-control-peer.test.mjs b/cloud/dev/scripts/relay-load-control-peer.test.mjs index 08bb3291a11f..2a41ae16d9cc 100644 --- a/cloud/dev/scripts/relay-load-control-peer.test.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.test.mjs @@ -1003,3 +1003,34 @@ for (const outcome of ['success', 'failure']) { }) } } + +test('a twin shares its host keys and connects with a given assignment, not the director', async (context) => { + const originalFetch = global.fetch + context.after(() => { + global.fetch = originalFetch + }) + const fetched = [] + global.fetch = async (url) => { + fetched.push(String(url)) + throw new Error('the director must not be asked') + } + const primary = new RelayLoadControlPeer(0, peerOptions(), () => undefined) + const twin = new RelayLoadControlPeer(0, peerOptions({ keys: primary.keys }), () => undefined) + assert.equal(twin.relayHostId, primary.relayHostId) + const socket = fakeHandshakeSocket() + let socketCell + twin.createSocket = (assignment) => { + socketCell = assignment.cellUrl + openOnNextTurn(socket) + return socket + } + + const connecting = twin.connect({ cellUrl: 'https://c3.relay.test', assignmentEpoch: 7 }) + await new Promise((resolve) => setTimeout(resolve, 10)) + await twin.shutdown() + await connecting.catch(() => undefined) + + assert.equal(socketCell, 'https://c3.relay.test') + assert.equal(JSON.parse(socket.sent[0]).assignmentEpoch, 7) + assert.equal(fetched.some((url) => url.endsWith('/v1/assign')), false) +}) diff --git a/cloud/dev/scripts/relay-load-duplicate-assign.mjs b/cloud/dev/scripts/relay-load-duplicate-assign.mjs new file mode 100644 index 000000000000..4aceec865bfc --- /dev/null +++ b/cloud/dev/scripts/relay-load-duplicate-assign.mjs @@ -0,0 +1,66 @@ +// Step 5's equal-epoch case: two fresh assigns for one host that reach two director instances +// can each book a cell at the same epoch. The host connects with the first answer, and a twin +// with the same keys holds the other seat half-open, as a desktop that joined both would. +export async function proveRelayLoadDuplicateAssign({ pairs, holdMs, delay, failureReason }) { + const result = { + probes: pairs.length, + bothAnswered: 0, + splitCells: 0, + sameEpochSplits: 0, + joinedBoth: 0, + refusalsByReason: {}, + connectFailuresByReason: {}, + // Not secrets: synthetic load hosts, listed so the ledger rows can be checked afterwards. + splits: [] + } + const count = (bucket, error) => { + const reason = failureReason(error) + bucket[reason] = (bucket[reason] ?? 0) + 1 + } + const twins = [] + await Promise.all( + pairs.map(async ({ primary, twin }) => { + const answers = await Promise.allSettled([ + primary.requestAssignment(), + primary.requestAssignment() + ]) + for (const answer of answers) { + if (answer.status === 'rejected') count(result.refusalsByReason, answer.reason) + } + const [first, second] = answers + .filter((answer) => answer.status === 'fulfilled') + .map((answer) => answer.value) + if (!first) return + if (second) result.bothAnswered++ + const split = second !== undefined && second.cellUrl !== first.cellUrl + if (split) { + result.splitCells++ + if (second.assignmentEpoch === first.assignmentEpoch) result.sameEpochSplits++ + result.splits.push({ + relayHostId: primary.relayHostId, + first: { cellUrl: first.cellUrl, assignmentEpoch: first.assignmentEpoch }, + second: { cellUrl: second.cellUrl, assignmentEpoch: second.assignmentEpoch } + }) + } + try { + await primary.connect(first) + } catch (error) { + count(result.connectFailuresByReason, error) + return + } + if (!split) return + try { + await twin.connect(second) + result.joinedBoth++ + twins.push(twin) + } catch (error) { + count(result.connectFailuresByReason, error) + } + }) + ) + if (twins.length > 0) { + await delay(holdMs) + await Promise.all(twins.map((twin) => twin.shutdown())) + } + return result +} diff --git a/cloud/dev/scripts/relay-load-duplicate-assign.test.mjs b/cloud/dev/scripts/relay-load-duplicate-assign.test.mjs new file mode 100644 index 000000000000..aaf15d5d4aa2 --- /dev/null +++ b/cloud/dev/scripts/relay-load-duplicate-assign.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { proveRelayLoadDuplicateAssign } from './relay-load-duplicate-assign.mjs' + +const c2 = 'https://c2.relay-staging.onorca.dev' +const c3 = 'https://c3.relay-staging.onorca.dev' + +function pair(relayHostId, answers, { twinFails = false } = {}) { + const queue = [...answers] + const log = { primary: [], twin: [], twinShutdowns: 0 } + const primary = { + relayHostId, + requestAssignment: async () => { + const answer = queue.shift() + if (answer instanceof Error) throw answer + return answer + }, + connect: async (assignment) => { + log.primary.push(assignment) + } + } + const twin = { + connect: async (assignment) => { + if (twinFails) throw new Error('control closed: 4409 wrong cell') + log.twin.push(assignment) + }, + shutdown: async () => { + log.twinShutdowns++ + } + } + return { pair: { primary, twin }, log } +} + +const failureReason = (error) => (error.message.startsWith('control closed: 4409') ? 'control_close_4409' : 'other') + +test('counts same-epoch splits, joins the second cell with a twin, then lets it go', async () => { + const split = pair('aaaaaaaaaaaaaaaa', [ + { cellUrl: c2, assignmentEpoch: 5 }, + { cellUrl: c3, assignmentEpoch: 5 } + ]) + const same = pair('bbbbbbbbbbbbbbbb', [ + { cellUrl: c2, assignmentEpoch: 3 }, + { cellUrl: c2, assignmentEpoch: 3 } + ]) + const delays = [] + const result = await proveRelayLoadDuplicateAssign({ + pairs: [split.pair, same.pair], + holdMs: 20_000, + delay: async (ms) => { + delays.push(ms) + }, + failureReason + }) + + assert.equal(result.probes, 2) + assert.equal(result.bothAnswered, 2) + assert.equal(result.splitCells, 1) + assert.equal(result.sameEpochSplits, 1) + assert.equal(result.joinedBoth, 1) + assert.deepEqual(result.splits, [ + { + relayHostId: 'aaaaaaaaaaaaaaaa', + first: { cellUrl: c2, assignmentEpoch: 5 }, + second: { cellUrl: c3, assignmentEpoch: 5 } + } + ]) + assert.deepEqual(split.log.primary, [{ cellUrl: c2, assignmentEpoch: 5 }]) + assert.deepEqual(split.log.twin, [{ cellUrl: c3, assignmentEpoch: 5 }]) + assert.equal(split.log.twinShutdowns, 1) + assert.deepEqual(same.log.twin, []) + assert.deepEqual(delays, [20_000]) +}) + +test('a split at different epochs is not a same-epoch split', async () => { + const split = pair('cccccccccccccccc', [ + { cellUrl: c2, assignmentEpoch: 5 }, + { cellUrl: c3, assignmentEpoch: 6 } + ]) + const result = await proveRelayLoadDuplicateAssign({ + pairs: [split.pair], + holdMs: 1_000, + delay: async () => undefined, + failureReason + }) + assert.equal(result.splitCells, 1) + assert.equal(result.sameEpochSplits, 0) +}) + +test('a refused second assign still connects the host once and holds nothing', async () => { + const refused = pair('dddddddddddddddd', [ + { cellUrl: c2, assignmentEpoch: 2 }, + new Error('relay assignment failed: 503') + ]) + const delays = [] + const result = await proveRelayLoadDuplicateAssign({ + pairs: [refused.pair], + holdMs: 20_000, + delay: async (ms) => { + delays.push(ms) + }, + failureReason + }) + assert.equal(result.bothAnswered, 0) + assert.deepEqual(result.refusalsByReason, { other: 1 }) + assert.deepEqual(refused.log.primary, [{ cellUrl: c2, assignmentEpoch: 2 }]) + assert.deepEqual(delays, []) +}) + +test('a twin the second cell refuses is counted, not joined', async () => { + const split = pair( + 'eeeeeeeeeeeeeeee', + [ + { cellUrl: c2, assignmentEpoch: 5 }, + { cellUrl: c3, assignmentEpoch: 5 } + ], + { twinFails: true } + ) + const result = await proveRelayLoadDuplicateAssign({ + pairs: [split.pair], + holdMs: 20_000, + delay: async () => undefined, + failureReason + }) + assert.equal(result.joinedBoth, 0) + assert.deepEqual(result.connectFailuresByReason, { control_close_4409: 1 }) + assert.equal(split.log.twinShutdowns, 0) +}) diff --git a/cloud/dev/scripts/relay-load-profile.mjs b/cloud/dev/scripts/relay-load-profile.mjs index b68b7567e5f1..6a78e9f15db8 100644 --- a/cloud/dev/scripts/relay-load-profile.mjs +++ b/cloud/dev/scripts/relay-load-profile.mjs @@ -49,6 +49,8 @@ export function parseRelayLoadArguments(argv) { const placementOverflowProbes = integer('--placement-overflow-probes', 0) const regionalFallbackProbes = integer('--regional-fallback-probes', 0) const regionBehaviorProbes = integer('--region-behavior-probes', 0) + const duplicateAssignProbes = integer('--duplicate-assign-probes', 0) + const duplicateAssignHoldMs = integer('--duplicate-assign-hold-ms', 20_000) const requestUnitInvites = integer('--request-unit-invites', 0) const requestUnitInvitesPerSecond = integer('--request-unit-invites-per-second', 0) const requestUnitPrincipalCount = integer('--request-unit-principals', 0) @@ -218,6 +220,15 @@ export function parseRelayLoadArguments(argv) { !directorOrigin || !capacityCellOrigin || preferredRegionValue(values) !== 'asia-east2' || (shardCount > 1 && shardIndex !== 0) )) throw new Error('regional behavior proof requires the coordinating Asia shard') + if (duplicateAssignProbes > 1_000) { + throw new Error('--duplicate-assign-probes cannot exceed 1000') + } + if (duplicateAssignProbes > 0 && !directorOrigin) { + throw new Error('duplicate assign probes require --director-origin') + } + if (duplicateAssignHoldMs < 1_000 || duplicateAssignHoldMs > 120_000) { + throw new Error('--duplicate-assign-hold-ms must be between 1000 and 120000') + } if (phaseBarrierDir && shardCount < 2) { throw new Error('phase barrier requires multiple shards') } @@ -276,6 +287,8 @@ export function parseRelayLoadArguments(argv) { placementOverflowProbes, regionalFallbackProbes, regionBehaviorProbes, + duplicateAssignProbes, + duplicateAssignHoldMs, requestUnitInvites, requestUnitInvitesPerSecond, requestUnitPrincipalCount, diff --git a/cloud/dev/scripts/relay-load-profile.test.mjs b/cloud/dev/scripts/relay-load-profile.test.mjs index 3609ba64f2e1..9763d6a699f3 100644 --- a/cloud/dev/scripts/relay-load-profile.test.mjs +++ b/cloud/dev/scripts/relay-load-profile.test.mjs @@ -393,3 +393,20 @@ test('requires queue, memory, and expected close evidence', () => { /did not close with 4429/ ) }) + +test('bounds duplicate-assign probes to a director run', () => { + const config = parseRelayLoadArguments([...required, '--duplicate-assign-probes', '100']) + assert.equal(config.duplicateAssignProbes, 100) + assert.equal(config.duplicateAssignHoldMs, 20_000) + assert.equal(parseRelayLoadArguments(required).duplicateAssignProbes, 0) + assert.throws(() => parseRelayLoadArguments([ + '--auth-origin', 'https://auth.test', '--target-origin', 'https://cell.test', + '--duplicate-assign-probes', '1' + ]), /require --director-origin/) + assert.throws(() => parseRelayLoadArguments([ + ...required, '--duplicate-assign-probes', '1001' + ]), /cannot exceed 1000/) + assert.throws(() => parseRelayLoadArguments([ + ...required, '--duplicate-assign-probes', '1', '--duplicate-assign-hold-ms', '500' + ]), /between 1000 and 120000/) +}) diff --git a/cloud/dev/scripts/relay-public-workflow-contract.test.mjs b/cloud/dev/scripts/relay-public-workflow-contract.test.mjs index 486914aaf3ef..3f34e96a1078 100644 --- a/cloud/dev/scripts/relay-public-workflow-contract.test.mjs +++ b/cloud/dev/scripts/relay-public-workflow-contract.test.mjs @@ -20,7 +20,7 @@ const UNGATED = relayWorkflowFile('verify.yml') const relayWorkflows = () => workflowFiles().filter((file) => file !== UNGATED) test('the copy carries every relay workflow', () => { - assert.equal(relayWorkflows().length, 26) + assert.equal(relayWorkflows().length, 27) }) // Why: workflow_run chains match by display name, not filename. Renaming a file is safe; renaming diff --git a/cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs b/cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs new file mode 100644 index 000000000000..07636c267689 --- /dev/null +++ b/cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +// Why: each metric keys on the event name and fields the relay writes. A rename on either side +// silences the alert without failing anything. + +const read = (relative) => readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8') +const terraform = read('../../infra/terraform/relay-observability.tf') +const deadMan = read('../../apps/relay/src/cell-reserve-dead-man.ts') +const registry = read('../../apps/relay/src/host-session-registry.ts') +const server = read('../../apps/relay/src/relay-server.ts') + +const block = (kind, name) => { + const body = new RegExp(`resource "${kind}" "${name}" \\{([\\s\\S]*?)\\n\\}`).exec(terraform)?.[1] + assert.ok(body, `${kind}.${name} not found in relay-observability.tf`) + return body +} + +test('the metric counts the event and cell the dead-man writes', () => { + assert.match(deadMan, /event: 'orca_relay_cell_reserve_dead_man_tripped',\s*cellId: this\.cellId,/) + const metric = block('google_logging_metric', 'relay_cell_reserve_dead_man_tripped') + assert.ok(metric.includes('jsonPayload.event=\\"orca_relay_cell_reserve_dead_man_tripped\\"')) + assert.ok(metric.includes('cell_id = "EXTRACT(jsonPayload.cellId)"')) +}) + +test('the alert fires on one trip per cell and notifies', () => { + const alert = block('google_monitoring_alert_policy', 'relay_cell_reserve_dead_man_tripped') + // One line per switch-file generation, so a higher bar misses a trip. + assert.match(alert, /threshold_value = 0\n/) + assert.match(alert, /notification_channels = var\.relay_alert_notification_channels/) + assert.ok(alert.includes('metric.type=\\"logging.googleapis.com/user/orca_relay_cell_reserve_dead_man_tripped\\"')) + assert.ok(alert.includes('group_by_fields = ["metric.label.\\"cell_id\\""]')) +}) + +const notifyingOnAnyLine = (name, event, resource, group) => { + const alert = block('google_monitoring_alert_policy', name) + assert.match(alert, /threshold_value = 0\n/) + assert.match(alert, /notification_channels = var\.relay_alert_notification_channels/) + assert.ok(alert.includes(`resource.type=\\"${resource}\\" AND metric.type=\\"logging.googleapis.com/user/${event}\\"`)) + assert.ok(alert.includes(`group_by_fields = ["metric.label.\\"${group}\\""]`)) +} + +test('the flip-back alerts count the lines the cell writes, per cell', () => { + for (const event of ['orca_relay_cell_reregistration_gave_up', 'orca_relay_cell_reregistration_stalled']) { + // logIdentity() carries cellId; both lines name the reason. + assert.match(registry, new RegExp(`event: '${event}',\\s*\\.\\.\\.this\\.logIdentity\\(\\),[\\s\\S]{0,200}reason:`)) + const name = event.replace('orca_', '') + const metric = block('google_logging_metric', name) + assert.ok(metric.includes(`resource.type=\\"gce_instance\\" AND jsonPayload.event=\\"${event}\\"`)) + assert.ok(metric.includes('cell_id = "EXTRACT(jsonPayload.cellId)"')) + assert.ok(metric.includes('reason = "EXTRACT(jsonPayload.reason)"')) + notifyingOnAnyLine(name, event, 'gce_instance', 'cell_id') + } + assert.match(registry, /cellId: this\.config\.cellId/) +}) + +test('the director alert counts its placement-off line on the director service', () => { + const event = 'orca_relay_reserve_placement_off_with_reserve_cells' + assert.match(server, new RegExp(`event: '${event}',\\s*cells,`)) + const metric = block('google_logging_metric', 'relay_reserve_placement_off_with_reserve_cells') + assert.ok( + metric.includes( + `resource.type=\\"cloud_run_revision\\" AND resource.labels.service_name=\\"\${var.relay_cloud_run_service_name}\\" AND jsonPayload.event=\\"${event}\\"` + ) + ) + notifyingOnAnyLine('relay_reserve_placement_off_with_reserve_cells', event, 'cloud_run_revision', 'revision') +}) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 67225f4d8690..71e5e7aa9219 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -42,6 +42,8 @@ const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' // C31 launches on the digest the director served when it was declared. const c31Digest = 'f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269' +// Staging C4 runs the step-5 image D for the staging e2e. +const stagingC4Digest = 'ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02' function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) @@ -52,8 +54,8 @@ function cellBlock(tfvars, cellId) { const productionCell = (cellId) => cellBlock(productionTfvars, cellId) // Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin. -test('pins staging C4 and the launch Asia cells to one image, and C30/C31 to director images', () => { - assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`)) +test('pins staging C4 to the step-5 image, the launch Asia cells to one image, and C30/C31 to director images', () => { + assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${stagingC4Digest}"`)) for (const cellId of asiaCells) { assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId) } diff --git a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs index 8afbfb5ca949..1f4d946557c0 100644 --- a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs +++ b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs @@ -22,12 +22,13 @@ const stagingTfvars = readFileSync( 'utf8' ) -// The exact five staging Relay workflows the relay-owned deploy identity serves. +// The exact six staging Relay workflows the relay-owned deploy identity serves. const DEPLOY_WORKFLOWS = [ 'bootstrap-relay-staging-capacity.yml', 'deploy-relay-staging-gce-candidate.yml', 'deploy-relay-staging.yml', 'operate-relay-asia-admission.yml', + 'operate-relay-staging-cell-flags.yml', 'power-relay-staging.yml' ] @@ -74,7 +75,7 @@ test('no Relay workflow authenticates as the shared staging deploy identity', () } }) -test('the five staging Relay workflows name the relay deploy pair', () => { +test('the six staging Relay workflows name the relay deploy pair', () => { for (const name of DEPLOY_WORKFLOWS) { const source = workflow(name) assert.match(source, /vars\.STAGING_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER\b/, name) @@ -90,7 +91,7 @@ test('the Asia admission production arm keeps the production deploy pair', () => assert.match(source, /vars\.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT\b/) }) -test('the provider allowlists exactly those five workflow refs', () => { +test('the provider allowlists exactly those six workflow refs', () => { const files = providerWorkflowFiles() assert.deepEqual([...files].sort(), [...DEPLOY_WORKFLOWS].sort()) for (const file of files) { @@ -137,7 +138,7 @@ test('the rendered attribute condition stays inside the provider limit', () => { `(${workflowRefs.map((ref) => `assertion.workflow_ref == '${ref}'`).join(' || ')})` ].join(' && ') assert.ok(rendered.length < 4096, `rendered condition is ${rendered.length} characters`) - assert.equal(rendered.length, 791) + assert.equal(rendered.length, 913) }) // Why: the census is the point. A binding added here without a workflow step behind it, or one @@ -223,3 +224,14 @@ test('the identity is exposed through its own outputs', () => { assert.match(outputs, /output "github_staging_relay_deploy_workload_identity_provider"/) assert.match(outputs, /output "github_staging_relay_deploy_service_account"/) }) + +// Why: relay-deploy-workflow-argv.test.mjs proves the argv parses; this pins the identity it names. +test('the staging director deploy runs as relay-dir with its rehome identity', () => { + const source = workflow('deploy-relay-staging.yml') + const start = source.indexOf('node dev/scripts/deploy-relay-blue-green.mjs') + const block = source.slice(start, source.indexOf('\n\n', start)) + assert.match(block, /--runtime-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/) + assert.match(block, /--rehome-director-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/) + assert.match(source, /DIRECTOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay-dir@/) + assert.match(block, /--deploy-service-account "\$\{DEPLOY_SERVICE_ACCOUNT\}"/) +}) diff --git a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs index 5bdc40fcd780..490bfde35206 100644 --- a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs +++ b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs @@ -15,7 +15,7 @@ const EXPECTED_CONDITIONS = { github_staging_relay_capacity: "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-recover-relay-staging-c4-image.yml@refs/heads/main')", github_staging_relay_deploy: - "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-staging-cell-flags.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')", github_relay_asia_topology: "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'", github_relay_asia_proof: diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 1cb260e11ea6..d26a0ee23f53 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -39,7 +39,11 @@ relay_gce_cells = { boot_disk_gb = 30 boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-7" capacity_requests = 4000 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:2d0f6e6db2b0eb9d6aba188698de8330f8c30b4e76badfcf0fac3f3eb9508a87" + # Step-5 image D (#27049-#27107). Never 2d0f6e6: it cannot report capped telemetry. + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" + # Step-5 staging e2e: c1 is the second US reserve cell, so it needs a cap and rehome trust. + connection_hard_cap = 600 + connection_unobserved_bound = 60 } "staging-gce-c2" = { hostname = "c2" @@ -48,7 +52,7 @@ relay_gce_cells = { boot_disk_gb = 30 boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-7" capacity_requests = 4000 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:1239830d0946dc92ded3c9edde1c0b827f584a7a2be5c177beed900056d76f69" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" connection_hard_cap = 600 connection_unobserved_bound = 60 } @@ -73,14 +77,15 @@ relay_gce_cells = { boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" capacity_requests = 6000 database_pool_max = 10 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" initially_enabled = false connection_hard_cap = 3000 connection_unobserved_bound = 60 } } -relay_region_rehome_source_cell_ids = ["staging-gce-c2", "staging-gce-c3"] +# c1 and c4 hold rehome trust for the step-5 staging e2e: the reserve and demote routes need it. +relay_region_rehome_source_cell_ids = ["staging-gce-c1", "staging-gce-c2", "staging-gce-c3", "staging-gce-c4"] # Push is currently provisioned only in production. push_gateway_enabled = false diff --git a/cloud/infra/terraform/relay-observability.tf b/cloud/infra/terraform/relay-observability.tf index a048a31667ff..e1f99cef4fc7 100644 --- a/cloud/infra/terraform/relay-observability.tf +++ b/cloud/infra/terraform/relay-observability.tf @@ -1539,3 +1539,323 @@ resource "google_monitoring_alert_policy" "relay_assignment_lease_bad_signature" depends_on = [google_logging_metric.relay_assignment_lease_shadow] } + +# relay-control-flag-channel.ts logs one line per change of cause, not per failed poll, and nothing +# on recovery: a cell stuck unreadable writes exactly one line, so any count is the signal. +resource "google_logging_metric" "relay_control_flags_unreadable" { + project = var.project_id + name = "orca_relay_control_flags_unreadable" + description = "Cells that started failing to read their switch file (cells/.json in the relay-control bucket). The cell keeps its last applied switches." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_control_flags_unreadable\"" + label_extractors = { + cell_id = "REGEXP_EXTRACT(jsonPayload.object, \"cells/([^.]+)\")" + failure = "EXTRACT(jsonPayload.failure)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + labels { + key = "failure" + value_type = "STRING" + description = "metadata, not-found, http, network, malformed, or void (the object failed the parser)." + } + } +} + +# Console only until the step-3/5 roll finishes: a single storage 5xx on one cell also writes a line. +resource "google_monitoring_alert_policy" "relay_control_flags_unreadable" { + project = var.project_id + display_name = "Orca Relay: cell cannot read its switch file" + combiner = "OR" + enabled = true + notification_channels = [] + + conditions { + display_name = "Switch-file read failures on a cell" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_control_flags_unreadable\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A cell logged `orca_relay_control_flags_unreadable`: its read of `cells/.json` in `-relay-control` failed, and it keeps its last applied switches (defaults, all off, if it never read one). The line is written once per change of cause and nothing marks recovery, so this incident closing does not mean the cell recovered: compare the object's generation with the cell's last `orca_relay_cell_flags_applied` line. `failure` says why: `not-found` (no object for this cell), `metadata` or `http` (token, IAM, or storage), `network`, `malformed` or `void` (the object is unparseable or failed the schema; fix the object). Many cells at once points to the bucket or its IAM. A one-off `http` or `network` that the next poll cleared needs nothing." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_control_flags_unreadable] +} + +# cell-reserve-dead-man.ts logs once per switch-file generation when it trips, so any count is the signal. +resource "google_logging_metric" "relay_cell_reserve_dead_man_tripped" { + project = var.project_id + name = "orca_relay_cell_reserve_dead_man_tripped" + description = "Reserve-mode cells that heard from no placing director for their dead-man window (60-120 s) and went back to admitting through the database." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_cell_reserve_dead_man_tripped\"" + label_extractors = { + cell_id = "EXTRACT(jsonPayload.cellId)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + } +} + +resource "google_monitoring_alert_policy" "relay_cell_reserve_dead_man_tripped" { + project = var.project_id + display_name = "Orca Relay: reserve cell lost its placing director" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A reserve-mode cell tripped its dead-man" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cell_reserve_dead_man_tripped\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A cell with `admitMode=reserve` in its switch file heard from no placing director (no booking, no feed poll marked `reserver=1`) for its dead-man window, so it went back to database admission and is re-registering its controls. Nobody is disconnected. Postgres still records the cell as reserve, so sweeps keep skipping it while it is heartbeat-live. First find why placement stopped: a director rollback, placement turned off, or every director failing its feed polls. Then, once the cell's `runtime-status` reports `admitModeEffective=db`, run `cloud-operate-relay-production-cell-flags.yml` with `--set admitMode=db` for that cell: it writes db into the switch file and records db in Postgres, so sweeps resume. **This step is required, within hours.** Until it runs, sweeps skip the cell, so its `reserved_requests` and leases only rise, until the database path treats the cell as full. The trip is latched for this switch-file generation: the cell will not return to reserve until a new reserve write, even if a director comes back. Several cells at once points to the directors, not the cells." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_cell_reserve_dead_man_tripped] +} + +# host-session-registry.ts: a flip back closed a control it could not re-register (4409), after 20 refusals. +resource "google_logging_metric" "relay_cell_reregistration_gave_up" { + project = var.project_id + name = "orca_relay_cell_reregistration_gave_up" + description = "Controls a cell closed with 4409 during a flip back to db, because the database kept refusing their lease." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_cell_reregistration_gave_up\"" + label_extractors = { + cell_id = "EXTRACT(jsonPayload.cellId)" + reason = "EXTRACT(jsonPayload.reason)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + labels { + key = "reason" + value_type = "STRING" + description = "The refusal: wrong_assignment (the row names another seat) or another database answer." + } + } +} + +resource "google_monitoring_alert_policy" "relay_cell_reregistration_gave_up" { + project = var.project_id + display_name = "Orca Relay: flip back closed a desktop it could not re-register" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A cell closed a control during its flip back" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cell_reregistration_gave_up\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "During a flip back to db, a cell asked the database for a control's lease 20 times and was refused each time, so it closed that desktop with 4409 and the desktop re-assigned. `reason` says why: `wrong_assignment` means the host's row names another seat (normally the ledger reconcile repairs that first); anything else is a database answer the lease path does not accept, which needs a code look. A handful on one flip is tolerable; a steady stream means the flip back is disconnecting desktops: stop flipping further cells." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_cell_reregistration_gave_up] +} + +# host-session-registry.ts: one line per 20 consecutive retries of a database that did not answer. +resource "google_logging_metric" "relay_cell_reregistration_stalled" { + project = var.project_id + name = "orca_relay_cell_reregistration_stalled" + description = "Controls whose flip-back lease has failed 20 times in a row because the database did not answer." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_cell_reregistration_stalled\"" + label_extractors = { + cell_id = "EXTRACT(jsonPayload.cellId)" + reason = "EXTRACT(jsonPayload.reason)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + labels { + key = "reason" + value_type = "STRING" + description = "The last database error." + } + } +} + +resource "google_monitoring_alert_policy" "relay_cell_reregistration_stalled" { + project = var.project_id + display_name = "Orca Relay: flip back stalled on the database" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A cell keeps retrying flip-back leases against an unanswering database" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cell_reregistration_stalled\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A cell flipping back to db has retried a control's lease 20 times in a row because the database did not answer (a stall, dropped connections, a busy lock). Nobody is disconnected: the cell keeps every control and retries for as long as it is open, and it reports `admitModeEffective=reserve` until it is done, so Postgres keeps reserve and sweeps stay off it. Look at Cloud SQL first. The flag workflow's wait may time out meanwhile; re-run it with `--set admitMode=db` once the database recovers." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_cell_reregistration_stalled] +} + +# relay-server.ts (director): placement is off while the map shows reserve cells; logged once a minute. +resource "google_logging_metric" "relay_reserve_placement_off_with_reserve_cells" { + project = var.project_id + name = "orca_relay_reserve_placement_off_with_reserve_cells" + description = "Directors running with reserve placement off while some cells report reserve mode." + filter = "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"${var.relay_cloud_run_service_name}\" AND jsonPayload.event=\"orca_relay_reserve_placement_off_with_reserve_cells\"" + label_extractors = { + revision = "EXTRACT(resource.labels.revision_name)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "revision" + value_type = "STRING" + description = "Cloud Run revision of the director." + } + } +} + +resource "google_monitoring_alert_policy" "relay_reserve_placement_off_with_reserve_cells" { + project = var.project_id + display_name = "Orca Relay: director placement off while cells are in reserve" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A director cannot place onto reserve cells" + + condition_threshold { + filter = "resource.type=\"cloud_run_revision\" AND metric.type=\"logging.googleapis.com/user/orca_relay_reserve_placement_off_with_reserve_cells\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"revision\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A director is not placing (`placement` in the line: off, dry-run, or configured on but never built; `configured` says what `ORCA_RELAY_RESERVE_PLACEMENT` asked for) while its map shows cells in reserve mode, so it books nothing there and leaves their hosts to the other directors. A director that predates step 5 logs nothing, so a rollback past it shows only as dead-man trips. If every director has placement off, each reserve cell's dead-man trips within 60-120 s (`Orca Relay: reserve cell lost its placing director`). If only some are (a partial rollback, or a manual traffic split), the rest keep the dead-man from tripping, and this alert is the only sign. Put every director on the step-5 revision with placement on, or flip the reserve cells to db (`cloud-operate-relay-production-cell-flags.yml --set admitMode=db`) before rolling the directors back." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_reserve_placement_off_with_reserve_cells] +} diff --git a/cloud/infra/terraform/relay-staging-deploy-iam.tf b/cloud/infra/terraform/relay-staging-deploy-iam.tf index ac5f5632d57d..a45652f2f682 100644 --- a/cloud/infra/terraform/relay-staging-deploy-iam.tf +++ b/cloud/infra/terraform/relay-staging-deploy-iam.tf @@ -1,13 +1,14 @@ # The staging Relay deploy identity. # # In production the shared `github_deploy` account is relay-owned; in staging it belongs to -# infra/terraform-apps and four app workflows can also mint it. These resources give the five +# infra/terraform-apps and four app workflows can also mint it. These resources give the six # staging Relay workflows their own account, provider, and bindings so the relay root owns every # credential its own workflows use. # # Bindings that already name local.relay_github_deploy_service_account_member follow that local # (relay-shared.tf) and are NOT repeated here: the staging power custom role, serviceAccountUser -# on relay_runtime and relay_director_runtime, and the three regional-placement secret bindings. +# on relay_runtime and relay_director_runtime, the three regional-placement secret bindings, and +# the cell-flags workflow's relay_control_workflow_writer. # Everything below replaces a grant the apps root still makes to `github_deploy`. locals { @@ -19,6 +20,7 @@ locals { "deploy-relay-staging-gce-candidate.yml", "deploy-relay-staging.yml", "operate-relay-asia-admission.yml", + "operate-relay-staging-cell-flags.yml", "power-relay-staging.yml" ] github_staging_relay_deploy_workflow_clauses = [ @@ -78,7 +80,7 @@ resource "google_service_account_iam_member" "github_staging_relay_deploy_worklo member = "principalSet://iam.googleapis.com/${local.relay_workload_identity_pool_name}/attribute.relay_ops_identity/staging-deploy" } -# Every one of the five runs `terraform init` (or `infra.mjs init --env staging`) first, and the +# Every one but the cell-flags workflow runs `terraform init` (or `infra.mjs init --env staging`) first, and the # GCS backend lists the bucket before it can open the state. Bucket metadata only, no object # access; this mirrors relay_fence_broker_bucket_reader. resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state_list" { @@ -90,7 +92,7 @@ resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state_l } # Reads reviewed topology: `terraform output -json relay_gce_cell_deployments` and the -# `terraform console` binds in Deploy Relay Staging. No step in the five applies, so this is +# `terraform console` binds in Deploy Relay Staging. No step in the six applies, so this is # objectViewer, not the capacity identity's objectAdmin, over the same two objects. resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state" { count = local.create_staging_relay_deploy_identity ? 1 : 0 @@ -110,7 +112,7 @@ resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state" } # Deploy Relay Staging step "Require the mirrored immutable image" runs -# `gcloud artifacts docker images describe`; nothing in the five writes to the repository. +# `gcloud artifacts docker images describe`; nothing in the six writes to the repository. resource "google_artifact_registry_repository_iam_member" "github_staging_relay_deploy_artifact_reader" { count = local.create_staging_relay_deploy_identity ? 1 : 0 diff --git a/cloud/infra/terraform/relay.tf b/cloud/infra/terraform/relay.tf index 5df7372ff074..217f92cfd470 100644 --- a/cloud/infra/terraform/relay.tf +++ b/cloud/infra/terraform/relay.tf @@ -174,6 +174,22 @@ resource "google_cloud_run_v2_service" "relay" { value = data.external.relay_serving_regional_placement_version.result.cohort_percent } + # The director deploy owns these (shadow cells are set by hand): an apply keeps whatever the + # serving revision has, and adds nothing when it has none. + dynamic "env" { + for_each = { + for name, value in { + ORCA_RELAY_RESERVE_PLACEMENT = data.external.relay_serving_regional_placement_version.result.reserve_placement + ORCA_RELAY_SHADOW_SEAT_FEED_CELLS = data.external.relay_serving_regional_placement_version.result.shadow_seat_feed_cells + } : name => value if value != "" + } + + content { + name = env.key + value = env.value + } + } + ports { container_port = 8080 } diff --git a/cloud/package.json b/cloud/package.json index 9ec7b478c912..88eed0a0549a 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -21,8 +21,8 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-reserve-dead-man-alert.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-duplicate-assign.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 index 9d58935f03a5..d9ebbf1f19d4 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -92,7 +92,6 @@ function Invoke-AppCell($Account,[string]$Descriptor,[string]$Log,[string]$Spec, New-Item -ItemType Directory -Force -Path $ReceiptRoot | Out-Null Push-Location $SourceRoot try { - if(-not (Test-Path -LiteralPath "out\relay\$Target\relay.js")){throw 'Build the relay before provisioning'} if(-not (Test-Path -LiteralPath 'out\orcad-template')){throw 'Build the orcad template before provisioning'} # ssh2 consults the runner's known_hosts: pin only this private endpoint, restored below. New-Item -ItemType Directory -Force -Path (Split-Path $knownPath) | Out-Null @@ -117,8 +116,8 @@ try { if($appCells.ContainsKey($cell)){ $code=Invoke-AppCell $account $descriptor (Join-Path $ReceiptRoot "$cell.log") $appCells[$cell][0] $appCells[$cell][1] } else { - # orcad cells deploy managed orcad instead of the relay; same account and descriptor shape. - $lane=if($cell.StartsWith('orcad-')){'src/main/ssh/orcad-windows-host-lane.test.ts'}else{'src/main/ssh/ssh-relay-windows-host-lane.test.ts'} + if(-not $cell.StartsWith('orcad-')){throw "Unknown Windows host cell $cell"} + $lane='src/main/ssh/orcad-windows-host-lane.test.ts' & node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts $lane --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log") # Why global: under the workflow's GetNewClosure callback, bare $LASTEXITCODE reads a stale captured copy. $code=$global:LASTEXITCODE diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt new file mode 100644 index 000000000000..777e5cca62a5 --- /dev/null +++ b/config/focus-setting-read-baseline.txt @@ -0,0 +1,505 @@ +# Renderer reads of the Active Server setting (member, element and destructuring reads of +# activeRuntimeEnvironmentId) plus calls of exported functions that read it, per file. +# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read +# the default host, through defaultCreationHost. Everything else routes by the owner. +# Prune after removing reads: pnpm check:owner-routing-ratchet --prune +2 src/renderer/src/App.tsx +5 src/renderer/src/app-shell/use-app-shell-services.ts +9 src/renderer/src/components/JiraIssueWorkspace.tsx +1 src/renderer/src/components/Landing.tsx +2 src/renderer/src/components/LinearIssueTextEditor.tsx +3 src/renderer/src/components/LinearIssueWorkspace.tsx +3 src/renderer/src/components/LinearItemDrawer.tsx +1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +1 src/renderer/src/components/activity/ActivityPrototypePage.tsx +1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx +2 src/renderer/src/components/activity/activity-thread-actions.ts +1 src/renderer/src/components/activity/use-agent-pane-threads.ts +4 src/renderer/src/components/agent/AgentSettingsDialog.tsx +1 src/renderer/src/components/automations/automation-host-client.ts +1 src/renderer/src/components/automations/automations-page-fixtures.ts +1 src/renderer/src/components/automations/use-automation-source-availability.ts +1 src/renderer/src/components/automations/use-automation-source-host-availability.ts +2 src/renderer/src/components/automations/use-automations-page-controller.ts +1 src/renderer/src/components/automations/use-automations-page-destination-form.ts +2 src/renderer/src/components/automations/use-automations-page-setup-state.ts +1 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.tsx +1 src/renderer/src/components/browser-pane/browser-reopen-on-server.ts +1 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts +1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts +1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts +2 src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts +1 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx +1 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts +1 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx +2 src/renderer/src/components/cmd-j/quick-action-context.ts +2 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +1 src/renderer/src/components/dashboard/launch-dashboard-agent.ts +1 src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +2 src/renderer/src/components/editor/EditorContent.tsx +1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx +1 src/renderer/src/components/editor/EditorEditFileSurface.tsx +1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx +1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +1 src/renderer/src/components/editor/csv/csv-link-routing.ts +2 src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts +1 src/renderer/src/components/editor/editor-model-lifetime-fixture.ts +1 src/renderer/src/components/editor/markdown-preview-link-actions.ts +1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts +1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts +2 src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts +2 src/renderer/src/components/editor/useEditorPanelContentState.ts +1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +2 src/renderer/src/components/editor/useMarkdownDocuments.ts +2 src/renderer/src/components/feature-wall/ConnectIntegrationsList.tsx +1 src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx +1 src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx +1 src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx +2 src/renderer/src/components/feature-wall/use-feature-wall-task-source-presentation.ts +2 src/renderer/src/components/feature-wall/use-integration-connection-status.ts +1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts +1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts +1 src/renderer/src/components/jira-connect-dialog.tsx +1 src/renderer/src/components/jira-user-picker.tsx +1 src/renderer/src/components/landing-preflight-runtime.ts +1 src/renderer/src/components/linear-api-key-dialog.tsx +3 src/renderer/src/components/linear-issue-project-selector.tsx +2 src/renderer/src/components/linear-issue-sub-issues.tsx +2 src/renderer/src/components/linear-issue-workspace-detail-state.ts +2 src/renderer/src/components/linear-item-drawer-comment-footer.tsx +6 src/renderer/src/components/linear-item-drawer-edit-controller.tsx +1 src/renderer/src/components/linear-item-drawer-edit-section.tsx +2 src/renderer/src/components/native-chat-resume-modal.test-support.tsx +1 src/renderer/src/components/native-chat-resume-tree.test-support.ts +1 src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx +1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx +1 src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx +1 src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +1 src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +5 src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts +2 src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +1 src/renderer/src/components/native-chat/native-chat-file-link.ts +3 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts +2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts +1 src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +1 src/renderer/src/components/native-chat/use-native-chat-file-drops.ts +1 src/renderer/src/components/native-chat/use-native-chat-link-actions.ts +2 src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts +1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts +4 src/renderer/src/components/new-workspace/use-linear-source-connection.ts +1 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts +1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts +1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts +1 src/renderer/src/components/quick-open-file-list-test-harness.ts +1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx +2 src/renderer/src/components/right-sidebar/FileExplorer.tsx +1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx +1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx +1 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts +1 src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +3 src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts +1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx +1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx +1 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +1 src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx +1 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts +1 src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx +1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx +1 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts +2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +2 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +2 src/renderer/src/components/right-sidebar/useFileSearchScope.ts +2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +1 src/renderer/src/components/settings/BaseRefPicker.tsx +2 src/renderer/src/components/settings/BrowserPane.tsx +1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +1 src/renderer/src/components/settings/GeneralPane.tsx +1 src/renderer/src/components/settings/IntegrationsPane.tsx +3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx +4 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +2 src/renderer/src/components/settings/Settings.tsx +1 src/renderer/src/components/settings/SettingsSetupGuidePane.tsx +1 src/renderer/src/components/settings/SettingsSidebar.tsx +1 src/renderer/src/components/settings/ShortcutsPane.tsx +2 src/renderer/src/components/settings/TasksPane.tsx +1 src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +1 src/renderer/src/components/settings/bitbucket-credentials-dialog.tsx +1 src/renderer/src/components/settings/bitbucket-integration-card.tsx +3 src/renderer/src/components/settings/cli-source-control-integration-cards.tsx +3 src/renderer/src/components/settings/jira-integration-card.tsx +1 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx +1 src/renderer/src/components/settings/provider-account-scope.ts +1 src/renderer/src/components/settings/settings-persistence-test-fixture.ts +2 src/renderer/src/components/settings/settings-setup-guide-progress.ts +1 src/renderer/src/components/settings/source-control-preflight-card-status.ts +2 src/renderer/src/components/settings/task-tracker-integration-cards.tsx +2 src/renderer/src/components/settings/token-source-control-integration-cards.tsx +2 src/renderer/src/components/settings/use-integration-provider-status-refresh.ts +1 src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts +2 src/renderer/src/components/settings/use-settings-navigation-model.ts +3 src/renderer/src/components/settings/use-settings-store-model.ts +2 src/renderer/src/components/settings/use-settings-terminal-model.ts +4 src/renderer/src/components/settings/use-task-source-provider-readiness.ts +1 src/renderer/src/components/setup-guide/SetupGuideModal.tsx +1 src/renderer/src/components/setup-guide/SetupGuideTelemetryObserver.tsx +2 src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +1 src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +3 src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +1 src/renderer/src/components/sidebar/SavedHostScopeWidenGate.tsx +1 src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx +1 src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +1 src/renderer/src/components/sidebar/SidebarAgentsList.tsx +1 src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx +1 src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx +1 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +1 src/renderer/src/components/sidebar/WorktreeCardPorts.tsx +2 src/renderer/src/components/sidebar/WorktreeList.tsx +1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx +1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +2 src/renderer/src/components/sidebar/delete-worktree-flow.ts +1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts +2 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +1 src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts +2 src/renderer/src/components/sidebar/sidebar-host-options.ts +1 src/renderer/src/components/sidebar/sidebar-project-drop.ts +2 src/renderer/src/components/sidebar/use-add-repo-host-selection.ts +1 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts +1 src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts +1 src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +1 src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts +1 src/renderer/src/components/sidebar/use-workspace-reference-details.ts +1 src/renderer/src/components/sidebar/use-worktree-card-controller.ts +1 src/renderer/src/components/sidebar/use-worktree-card-review-details.ts +2 src/renderer/src/components/sidebar/useSidebarProjectDrop.ts +1 src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts +2 src/renderer/src/components/sidebar/visible-worktrees.ts +3 src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts +1 src/renderer/src/components/sidebar/workspace-options-menu-items.tsx +2 src/renderer/src/components/sidebar/workspace-reference-details.ts +1 src/renderer/src/components/sidebar/workspace-reference-refresh-targets.ts +1 src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx +1 src/renderer/src/components/sidebar/worktree-filter-visibility.ts +2 src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +1 src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx +1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts +1 src/renderer/src/components/skills/SkillsPage.tsx +7 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx +1 src/renderer/src/components/status-bar/PortsStatusSegment.tsx +1 src/renderer/src/components/status-bar/SshStatusSegment.tsx +1 src/renderer/src/components/status-bar/StatusBarSurface.tsx +1 src/renderer/src/components/status-bar/ports-status-popover-rows.tsx +2 src/renderer/src/components/status-bar/provider-account-sync-key.ts +1 src/renderer/src/components/status-bar/status-bar-claude-accounts.ts +1 src/renderer/src/components/status-bar/status-bar-codex-accounts.ts +1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts +7 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts +1 src/renderer/src/components/status-bar/use-status-bar-controller.ts +1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts +1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts +1 src/renderer/src/components/status-bar/workspace-space-decision-details.ts +1 src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +1 src/renderer/src/components/tab-bar/TabBar.tsx +1 src/renderer/src/components/tab-bar/TabBarItemRow.tsx +3 src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts +4 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +2 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts +1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts +2 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts +5 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts +1 src/renderer/src/components/tab-group/workspace-tab-close-commands.ts +1 src/renderer/src/components/task-page-jira-status-order.ts +2 src/renderer/src/components/task-page-linear-issue-model.tsx +11 src/renderer/src/components/task-page/TaskPage.tsx +2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx +1 src/renderer/src/components/task-page/linear/Filters.tsx +1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +1 src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts +1 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +1 src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +1 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts +1 src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts +1 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts +1 src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts +2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts +1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts +2 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts +1 src/renderer/src/components/terminal-pane/terminal-keyboard-hook.ts +1 src/renderer/src/components/terminal-pane/terminal-keyboard-runtime.ts +1 src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +2 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts +2 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts +3 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts +2 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +2 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-paste-listeners.ts +1 src/renderer/src/components/terminal-workspace-model.ts +1 src/renderer/src/components/terminal/terminal-tab-actions.ts +4 src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts +2 src/renderer/src/components/terminal/terminal-tab-create.ts +2 src/renderer/src/components/use-new-linear-issue-projects.ts +4 src/renderer/src/components/use-task-page-jira-creation-metadata.ts +4 src/renderer/src/components/use-task-page-jira-issue-creation.ts +2 src/renderer/src/components/use-task-page-jira-list-effects.ts +2 src/renderer/src/components/use-task-page-jira-list-state.ts +4 src/renderer/src/components/use-task-page-linear-board.ts +6 src/renderer/src/components/use-task-page-linear-creation-state.ts +4 src/renderer/src/components/use-task-page-linear-issue-creation.ts +2 src/renderer/src/components/use-task-page-linear-project-creation.ts +2 src/renderer/src/components/use-task-page-provider-metadata.ts +2 src/renderer/src/components/use-task-page-source-availability.ts +2 src/renderer/src/components/use-task-page-store-bindings.ts +2 src/renderer/src/components/use-terminal-activation-actions.ts +2 src/renderer/src/components/use-terminal-bulk-close-actions.ts +1 src/renderer/src/components/use-terminal-controller.ts +8 src/renderer/src/components/use-terminal-create-actions.ts +1 src/renderer/src/components/use-terminal-watcher-effects.ts +2 src/renderer/src/components/use-worktree-jump-palette-controller.ts +1 src/renderer/src/components/use-worktree-jump-palette-create-action.ts +2 src/renderer/src/components/use-worktree-jump-palette-filter.ts +3 src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts +2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts +1 src/renderer/src/components/use-worktree-jump-palette-task-url.ts +1 src/renderer/src/components/worktree-jump-palette-create-worktree.ts +1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts +1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts +1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts +2 src/renderer/src/hooks/composer-state/composer-source-state.ts +1 src/renderer/src/hooks/composer-state/composer-target-state.ts +1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts +1 src/renderer/src/hooks/editor-external-watch-target-rows.ts +3 src/renderer/src/hooks/editor-external-watch-targets.ts +1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +1 src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts +6 src/renderer/src/hooks/ipc-events-test-harness.ts +2 src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +1 src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +1 src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts +1 src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts +2 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts +1 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts +2 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts +1 src/renderer/src/hooks/use-terminal-quick-command-hosts.ts +1 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +2 src/renderer/src/hooks/useAgentDetectionTarget.ts +1 src/renderer/src/hooks/useEditorExternalWatch.ts +1 src/renderer/src/hooks/useInstalledAgentSkills.ts +1 src/renderer/src/hooks/useIpcEvents.ts +4 src/renderer/src/hooks/useIssueMetadata.ts +1 src/renderer/src/hooks/useLinearProviderConnected.ts +7 src/renderer/src/hooks/useSettingsNavigationMetadata.ts +1 src/renderer/src/hooks/useUnreadDockBadge.ts +2 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts +4 src/renderer/src/lib/activate-ai-vault-structured-session.ts +1 src/renderer/src/lib/active-agent-note-send.ts +1 src/renderer/src/lib/active-agent-note-target.ts +1 src/renderer/src/lib/agent-background-session-launch-host.ts +1 src/renderer/src/lib/agent-detection-target-inventory.ts +1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts +3 src/renderer/src/lib/agent-hibernation-coordinator.ts +2 src/renderer/src/lib/agent-launch-route-input.ts +1 src/renderer/src/lib/agent-message-send.ts +1 src/renderer/src/lib/ai-vault-resume-platform.ts +1 src/renderer/src/lib/ai-vault-tab-title-requests.ts +2 src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts +1 src/renderer/src/lib/client-creation-action-error.ts +3 src/renderer/src/lib/client-creation-action-policy.ts +1 src/renderer/src/lib/codex-account-restart-startup.ts +1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host +1 src/renderer/src/lib/doc-preview-grants.ts +1 src/renderer/src/lib/empty-workspace-default-agent-chat.ts +3 src/renderer/src/lib/execute-open-editor-path-move.ts +1 src/renderer/src/lib/execution-host-display-label.ts +2 src/renderer/src/lib/execution-host-facts.ts +6 src/renderer/src/lib/file-preview.ts +2 src/renderer/src/lib/folder-workspace-runtime-owner.ts +1 src/renderer/src/lib/host-mirrored-pane-liveness.ts +6 src/renderer/src/lib/http-link-destinations.ts +3 src/renderer/src/lib/http-link-routing.ts +1 src/renderer/src/lib/launch-agent-background-session.ts +1 src/renderer/src/lib/launch-agent-execution-context.ts +2 src/renderer/src/lib/launch-agent-in-new-tab.ts +2 src/renderer/src/lib/launch-agent-web-host-tab.ts +2 src/renderer/src/lib/launch-ai-vault-session.ts +1 src/renderer/src/lib/launch-structured-agent-session.ts +1 src/renderer/src/lib/launch-worktree-background-terminals.ts +1 src/renderer/src/lib/linear-issue-url-lookup.ts +2 src/renderer/src/lib/local-preflight-context.ts +3 src/renderer/src/lib/open-agent-message-sender.ts +2 src/renderer/src/lib/provider-runtime-context.ts +2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts +1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts +4 src/renderer/src/lib/repo-runtime-owner.ts +2 src/renderer/src/lib/resolve-owner.ts +2 src/renderer/src/lib/resume-sleeping-agent-session.ts +1 src/renderer/src/lib/runtime-session-mirror-owners.ts +1 src/renderer/src/lib/runtime-session-mirror-targets.ts +1 src/renderer/src/lib/runtime-workspace-file-route.ts +1 src/renderer/src/lib/single-runtime-legacy-owner.ts +1 src/renderer/src/lib/sleeping-agent-session-launch.ts +1 src/renderer/src/lib/sleeping-record-execution-host-scope.ts +1 src/renderer/src/lib/structured-agent-session-launch.ts +1 src/renderer/src/lib/structured-agent-session-provisional-tab.ts +4 src/renderer/src/lib/structured-agent-session-tab-activation.ts +1 src/renderer/src/lib/tab-number-shortcuts.ts +1 src/renderer/src/lib/terminal-close-route.ts +1 src/renderer/src/lib/terminal-tab-owner-activation.ts +5 src/renderer/src/lib/terminal-worktree-route.ts +3 src/renderer/src/lib/unread-badge-count-selector.ts +2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +1 src/renderer/src/lib/workspace-browser-tab-close.ts +5 src/renderer/src/lib/workspace-browser-tab-open.ts +1 src/renderer/src/lib/workspace-file-drag-source.ts +1 src/renderer/src/lib/workspace-tab-commands.ts +1 src/renderer/src/lib/workspace-tab-palette-activation.ts +3 src/renderer/src/lib/workspace-terminal-host-authority.ts +1 src/renderer/src/lib/worktree-activation-pty-inventory.ts +3 src/renderer/src/lib/worktree-activation.ts +1 src/renderer/src/lib/worktree-creation-agent-seeds.ts +1 src/renderer/src/lib/worktree-initial-terminal-seeding.ts +1 src/renderer/src/lib/worktree-jump-navigation.ts +2 src/renderer/src/lib/worktree-operation-route.ts +1 src/renderer/src/lib/worktree-row-open-in-owner.ts +6 src/renderer/src/lib/worktree-runtime-owner.ts +1 src/renderer/src/lib/worktree-sort-order-host-split.ts +1 src/renderer/src/runtime/close-mirrored-editor-tab.ts +1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +17 src/renderer/src/runtime/runtime-jira-client.ts +2 src/renderer/src/runtime/runtime-jira-summary-client.ts +1 src/renderer/src/runtime/runtime-jira-target.ts +5 src/renderer/src/runtime/runtime-jira-user-fields-client.ts +8 src/renderer/src/runtime/runtime-linear-client.ts +6 src/renderer/src/runtime/runtime-linear-issue-mutations.ts +12 src/renderer/src/runtime/runtime-linear-project-client.ts +3 src/renderer/src/runtime/structured-agent-session-owner.ts +1 src/renderer/src/runtime/structured-conversation-name.ts +5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts +1 src/renderer/src/runtime/use-worktree-runtime-target.ts +2 src/renderer/src/runtime/web-runtime-browser-creation.ts +1 src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.ts +1 src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts +1 src/renderer/src/runtime/web-runtime-session-tab-move.ts +2 src/renderer/src/runtime/web-runtime-terminal-actions.ts +1 src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +3 src/renderer/src/runtime/web-runtime-terminal-creation.ts +1 src/renderer/src/runtime/web-runtime-worktree-activation.ts +1 src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +2 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts +1 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts +3 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts +1 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +1 src/renderer/src/store/github/repository-routing.ts +2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts +2 src/renderer/src/store/github/work-item-aggregate-actions.ts +3 src/renderer/src/store/github/work-item-fetch-actions.ts +2 src/renderer/src/store/github/work-item-routing.ts +9 src/renderer/src/store/index.ts +3 src/renderer/src/store/project-groups/project-group-mutations.ts +1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +1 src/renderer/src/store/repos/converted-ssh-browser-pages.ts +2 src/renderer/src/store/repos/repo-add-actions.ts +1 src/renderer/src/store/repos/repo-update.ts +1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +1 src/renderer/src/store/slices/browser-slice-test-harness.ts +1 src/renderer/src/store/slices/browser/browser-close-actions.ts +16 src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts +1 src/renderer/src/store/slices/browser/browser-host-actions.ts +10 src/renderer/src/store/slices/browser/browser-host-state.ts +2 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts +1 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts +14 src/renderer/src/store/slices/browser/browser-profile-import-actions.ts +14 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts +7 src/renderer/src/store/slices/browser/browser-tab-actions.ts +1 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts +1 src/renderer/src/store/slices/browser/create-browser-slice.ts +1 src/renderer/src/store/slices/browser/paired-browser-tab-creator.ts +3 src/renderer/src/store/slices/diff-comment-persistence.ts +2 src/renderer/src/store/slices/editor-slice-test-harness.ts +1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts +1 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts +1 src/renderer/src/store/slices/editor/actions/open-file-action.ts +1 src/renderer/src/store/slices/editor/actions/open-file-apply.ts +1 src/renderer/src/store/slices/editor/create-editor-slice.ts +1 src/renderer/src/store/slices/github-cache-key.ts +1 src/renderer/src/store/slices/hosted-review-cache-identity.ts +5 src/renderer/src/store/slices/jira-collection-read-actions.ts +31 src/renderer/src/store/slices/jira-connection-actions.ts +8 src/renderer/src/store/slices/jira-issue-read-actions.ts +6 src/renderer/src/store/slices/jira-read-coordination.ts +3 src/renderer/src/store/slices/jira.ts +1 src/renderer/src/store/slices/linear-slice-test-harness.ts +11 src/renderer/src/store/slices/linear.ts +5 src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts +5 src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts +6 src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts +5 src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts +1 src/renderer/src/store/slices/linear/linear-invalidation-actions.ts +2 src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts +6 src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts +9 src/renderer/src/store/slices/linear/linear-issue-list-actions.ts +5 src/renderer/src/store/slices/linear/linear-project-detail-actions.ts +5 src/renderer/src/store/slices/linear/linear-project-issue-actions.ts +6 src/renderer/src/store/slices/linear/linear-project-list-actions.ts +33 src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts +5 src/renderer/src/store/slices/linear/linear-slice-scope.ts +9 src/renderer/src/store/slices/linear/linear-slice-status-actions.ts +6 src/renderer/src/store/slices/linear/linear-team-actions.ts +2 src/renderer/src/store/slices/preflight.ts +1 src/renderer/src/store/slices/repo-host-identity.ts +1 src/renderer/src/store/slices/repo-reorder-host-split.ts +4 src/renderer/src/store/slices/repos.ts +1 src/renderer/src/store/slices/runtime-status.ts +2 src/renderer/src/store/slices/settings-owner-hydration-publication.ts +5 src/renderer/src/store/slices/settings.ts +8 src/renderer/src/store/slices/store-test-helpers.ts +1 src/renderer/src/store/slices/tabs/tabs-close-actions.ts +2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts +2 src/renderer/src/store/slices/terminal-tab-retirement.ts +1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts +3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts +3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts +7 src/renderer/src/store/slices/worktrees.ts +1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts +1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts +2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts +3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts +1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts +1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts +1 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +1 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts +1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts +1 src/renderer/src/store/terminals/terminal-shutdown.ts +1 src/renderer/src/store/terminals/terminal-tab-attention.ts +1 src/renderer/src/store/terminals/terminal-tab-close-providers.ts +1 src/renderer/src/store/terminals/terminal-workspace-routing.ts +1 src/renderer/src/web/preload-api/web-app-api.ts +3 src/renderer/src/web/preload-api/web-preference-normalization.ts +22 src/renderer/src/web/preload-api/web-preferences-store.ts +13 src/renderer/src/web/preload-api/web-settings-api.ts +9 src/renderer/src/web/preload-api/web-ui-api.ts +3 src/renderer/src/web/preload-api/web-workspace-session-api.ts +3 src/renderer/src/web/web-preload-api.ts diff --git a/config/knip.json b/config/knip.json index 512129707510..8bb86d8d2610 100644 --- a/config/knip.json +++ b/config/knip.json @@ -20,7 +20,6 @@ "src/renderer/src/web/main.tsx", "src/renderer/src/**/*.worker.ts", "src/cli/index.ts", - "src/relay/relay.ts", "src/wsl-guest/wsl-agent-hook-relay.ts", "config/scripts/**/*.{mjs,cjs,js,ts}", "config/build-plugins/**/*.ts", diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6d4e17066c8c..5e8ac38c8903 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -5,7 +5,4 @@ inline src/main/ipc/worktree-remote.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts -inline src/main/ssh/ssh-relay-deploy.ts -inline src/main/ssh/ssh-relay-session.ts -inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 7f4a13042581..df69f491126c 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -1,131 +1,4 @@ # Renderer call sites that route by the Active Server focus setting: # getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file. -# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead. -# Prune after removing sites: pnpm check:owner-routing-ratchet --prune -1 src/renderer/src/components/editor/EditorPanel.tsx -1 src/renderer/src/components/editor/ExternalFileChangeBanner.tsx -1 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx -1 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts -2 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts -1 src/renderer/src/components/editor/markdown-preview-link-actions.ts -1 src/renderer/src/components/editor/rich-markdown-image-context.ts -1 src/renderer/src/components/editor/rich-markdown-image-insert.ts -1 src/renderer/src/components/editor/rich-markdown-paste-image.ts -1 src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts -1 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts -1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts -2 src/renderer/src/components/editor/useMarkdownDocuments.ts -1 src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx -1 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx -2 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts -1 src/renderer/src/components/github-project/ProjectCellIssueType.tsx -4 src/renderer/src/components/github-project/project-picker-runtime.ts -1 src/renderer/src/components/github-project/slug-dialog/Comments.tsx -1 src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx -1 src/renderer/src/components/github/github-rate-limit-display.tsx -4 src/renderer/src/components/github/github-work-item-edit-mutations.ts -1 src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx -1 src/renderer/src/components/linear-api-key-dialog.tsx -1 src/renderer/src/components/ports/WorkspacePortScanner.tsx -1 src/renderer/src/components/pull-request-page/actions/panel.tsx -1 src/renderer/src/components/pull-request-page/reviewers/panel.tsx -2 src/renderer/src/components/pull-request-page/reviewers/request-actions.ts -1 src/renderer/src/components/quick-open-file-navigation.ts -1 src/renderer/src/components/settings/GeneralPane.tsx -1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx -1 src/renderer/src/components/settings/RepositoryIconPicker.tsx -1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts -1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts -1 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx -1 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts -1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts -1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts -2 src/renderer/src/components/task-page-github-reviewer-actions.ts -1 src/renderer/src/components/task-page/github/AssigneesCell.tsx -2 src/renderer/src/components/task-page/github/MergeCell.tsx -1 src/renderer/src/components/task-page/github/StatusCell.tsx -1 src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts -1 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts -1 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts -1 src/renderer/src/components/use-task-page-github-issue-draft.ts -1 src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts -1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts -1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts -1 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts -2 src/renderer/src/hooks/useGitHubSlugMetadata.ts -1 src/renderer/src/hooks/useIssueMetadata.ts -1 src/renderer/src/hooks/useSettingsNavigationMetadata.ts -3 src/renderer/src/lib/activate-ai-vault-structured-session.ts -1 src/renderer/src/lib/active-agent-note-send.ts -1 src/renderer/src/lib/active-agent-note-target.ts -1 src/renderer/src/lib/automation-session-observer.ts -1 src/renderer/src/lib/codex-pane-selection-lane.ts -1 src/renderer/src/lib/github-pr-start-point.ts -1 src/renderer/src/lib/github-source-runtime-context.ts -1 src/renderer/src/lib/hosted-review-repo-read.ts -1 src/renderer/src/lib/launch-agent-background-session.ts -1 src/renderer/src/lib/launch-worktree-background-terminals.ts -1 src/renderer/src/lib/local-file-access.ts -1 src/renderer/src/lib/repo-slug-cache.ts -1 src/renderer/src/lib/repo-slug-index.ts -1 src/renderer/src/lib/structured-agent-session-tab-activation.ts -1 src/renderer/src/lib/workspace-file-host-routing.ts -1 src/renderer/src/lib/worktree-creation-flow-startup.ts -1 src/renderer/src/runtime/mobile-markdown-bridge.ts -1 src/renderer/src/runtime/runtime-file-import-client.ts -1 src/renderer/src/runtime/runtime-file-metadata-client.ts -1 src/renderer/src/runtime/runtime-file-mutation-client.ts -1 src/renderer/src/runtime/runtime-file-range-client.ts -2 src/renderer/src/runtime/runtime-file-read-client.ts -4 src/renderer/src/runtime/runtime-file-routing.ts -4 src/renderer/src/runtime/runtime-file-search-client.ts -1 src/renderer/src/runtime/runtime-file-watch-client.ts -1 src/renderer/src/runtime/runtime-git-client-context.ts -5 src/renderer/src/runtime/runtime-git-diff-client.ts -5 src/renderer/src/runtime/runtime-git-generation-client.ts -6 src/renderer/src/runtime/runtime-git-status-client.ts -10 src/renderer/src/runtime/runtime-git-sync-client.ts -9 src/renderer/src/runtime/runtime-git-working-tree-client.ts -3 src/renderer/src/runtime/runtime-hooks-client.ts -1 src/renderer/src/runtime/runtime-jira-target.ts -1 src/renderer/src/runtime/runtime-linear-client.ts -3 src/renderer/src/runtime/runtime-repo-client.ts -3 src/renderer/src/runtime/runtime-terminal-inspection.ts -1 src/renderer/src/runtime/runtime-terminal-stream.ts -1 src/renderer/src/runtime/runtime-terminal-verified-input.ts -1 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts -2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts -3 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts -1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts -1 src/renderer/src/store/github/cache-identity.ts -1 src/renderer/src/store/github/project-actions.ts -1 src/renderer/src/store/github/project-field-mutations.ts -2 src/renderer/src/store/github/project-row-actions.ts -1 src/renderer/src/store/github/repository-routing.ts -1 src/renderer/src/store/github/work-item-mutation-actions.ts -6 src/renderer/src/store/project-groups/nested-repository-operations.ts -2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts -4 src/renderer/src/store/project-groups/project-group-mutations.ts -1 src/renderer/src/store/projects/project-host-routing.ts -1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts -2 src/renderer/src/store/repos/repo-add-actions.ts -2 src/renderer/src/store/repos/repo-catalog-actions.ts -1 src/renderer/src/store/repos/repo-removal.ts -1 src/renderer/src/store/repos/repo-update.ts -1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts -1 src/renderer/src/store/slices/diff-comment-persistence.ts -1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts -4 src/renderer/src/store/slices/hosted-review.ts -1 src/renderer/src/store/slices/preflight.ts -1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts -3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts -1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts -1 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts -2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts -1 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts -2 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts -3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts -1 src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts -1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts -1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts -1 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +# The helpers are deleted and this list is empty: any new use fails lint. Route by the +# resource's owner; only a creation flow with no source row may use defaultCreationHost. diff --git a/config/patches/oxlint-plugin-anti-slop@0.1.2.patch b/config/patches/oxlint-plugin-anti-slop@0.1.2.patch new file mode 100644 index 000000000000..1e7349f8facd --- /dev/null +++ b/config/patches/oxlint-plugin-anti-slop@0.1.2.patch @@ -0,0 +1,32 @@ +diff --git a/src/rules/no-object-parameters.ts b/src/rules/no-object-parameters.ts +index 6589ebf5f587ecdad1989b9f89886370f0ed5350..10eb7cf06d09802ce53dab6342d48f456bd55731 100644 +--- a/src/rules/no-object-parameters.ts ++++ b/src/rules/no-object-parameters.ts +@@ -63,6 +63,11 @@ export const noObjectParametersRule = defineRule({ + + return { + Program(node) { ++ if (!context.sourceCode.text.includes("object") && !context.sourceCode.text.includes("\\u")) { ++ environment = null; ++ return; ++ } ++ + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, +diff --git a/src/rules/no-unknown-type-aliases.ts b/src/rules/no-unknown-type-aliases.ts +index af5f08a5002fb26f94414be13f1ad848ae73d522..eb95ebc44fcb4b4760b2afe097a114cbabad4e7f 100644 +--- a/src/rules/no-unknown-type-aliases.ts ++++ b/src/rules/no-unknown-type-aliases.ts +@@ -36,6 +36,11 @@ export const noUnknownTypeAliasesRule = defineRule({ + + return { + Program(node) { ++ if (!context.sourceCode.text.includes("unknown") && !context.sourceCode.text.includes("\\u")) { ++ environment = null; ++ return; ++ } ++ + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, diff --git a/config/performance-audit.md b/config/performance-audit.md index 9c42dd3654b3..31915230babd 100644 --- a/config/performance-audit.md +++ b/config/performance-audit.md @@ -31,8 +31,7 @@ Incremental Claude scans and persisted Claude cache checkpoints are also include macOS and Windows, and on PRs changing this tooling or any listed contract file. It uploads per-OS JSON test results, plus the source inventory once from Linux because that scan is OS-independent. Its schedule starts after merge. Run the existing -`test:e2e:terminal-perf:scale:report` for rendered typing/frame budgets and -`test:e2e:ssh-docker-perf` for real transport behavior. Relay unit tests do not +`test:e2e:terminal-perf:scale:report` for rendered typing/frame budgets. Unit tests do not measure SSH RTT, WSL scheduling or a packaged Electron renderer. To extend coverage, select a production-path regression with an operation-count, diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 4b50f1549903..b85ebb00efa4 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -8,7 +8,6 @@ src/cli/handlers/skills.ts src/cli/runtime/launch.ts src/cli/runtime/serve-update-supervisor.ts src/main/agent-hooks/managed-hook-owner-identity.ts -src/main/agent-hooks/managed-hook-runtime.ts src/main/agent-hooks/wsl-hook-relay-launch.ts src/main/agent-hooks/wsl-hook-relay-link.ts src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -105,7 +104,6 @@ src/main/runtime/windows-mobile-firewall.ts src/main/skills/skill-update-run.ts src/main/ssh/ssh-connection.ts src/main/ssh/ssh-proxy-command.ts -src/main/ssh/ssh-remote-cli-host-passthrough.ts src/main/ssh/system-ssh-command.ts src/main/ssh/system-ssh-dynamic-forward-process.ts src/main/ssh/system-ssh-file-transfer.ts @@ -123,16 +121,6 @@ src/main/wsl-availability.ts src/main/wsl-unc-delete.ts src/main/wsl.ts src/main/zsh-startup-hook-pty-harness.ts -src/relay/agent-exec-handler.ts -src/relay/ai-vault-service-client-state.ts -src/relay/ai-vault-service-client.ts -src/relay/ai-vault-service-spawn.ts -src/relay/external-automations-handler.ts -src/relay/fs-handler-git-fallback.ts -src/relay/fs-handler-list-files.ts -src/relay/pty-shell-utils.ts -src/relay/subprocess-tree-termination.ts -src/relay/workspace-space-scan.ts src/shared/ephemeral-vm-recipe-process.ts src/shared/ephemeral-vm-recipe-runner.ts src/shared/fish-binary-requirement.ts diff --git a/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs b/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs deleted file mode 100644 index 051ea13cc464..000000000000 --- a/config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs +++ /dev/null @@ -1,71 +0,0 @@ -const { createHash } = require('node:crypto') -const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs') -const { join, resolve } = require('node:path') - -const EXPECTED_NODE_PTY_VERSION = '1.1.0' -const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52' -const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857' -const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);' -const PATCHED_BODY = `var consoleProcessList; -try { - consoleProcessList = getConsoleProcessList(shellPid); -} -catch (_a) { - // Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately. - consoleProcessList = [shellPid]; -}` - -function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) { - const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty') - const packageJsonPath = join(nodePtyDir, 'package.json') - const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js') - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) - if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) { - throw new Error( - `Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}` - ) - } - const source = readFileSync(agentPath, 'utf8') - return { agentPath, source } -} - -function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) { - const inspected = inspectNodePtyConsoleListAgent(relayDir) - if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) { - throw new Error('node-pty ConPTY console-list fallback is not installed') - } -} - -function patchNodePtyConsoleListAgent(relayDir = process.cwd()) { - const inspected = inspectNodePtyConsoleListAgent(relayDir) - const sourceHash = sourceSha256(inspected.source) - if (sourceHash === PATCHED_SOURCE_SHA256) { - return - } - if (sourceHash !== ORIGINAL_SOURCE_SHA256) { - throw new Error('Refusing to patch unexpected node-pty console-list agent source') - } - const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY) - const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}` - // Why: a terminated remote install must leave either known source version recoverable on reconnect. - try { - writeFileSync(temporaryPath, patchedSource) - renameSync(temporaryPath, inspected.agentPath) - } finally { - rmSync(temporaryPath, { force: true }) - } - assertPatchedNodePtyConsoleListAgent(relayDir) -} - -function sourceSha256(source) { - return createHash('sha256').update(source).digest('hex') -} - -if (require.main === module) { - patchNodePtyConsoleListAgent() -} - -module.exports = { - assertPatchedNodePtyConsoleListAgent, - patchNodePtyConsoleListAgent -} diff --git a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs deleted file mode 100644 index 40bc0350d86d..000000000000 --- a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Relay-side pty fd-leak patch for node-pty 1.1.0 (#17915). - * - * The app gets this through pnpm `patchedDependencies`; the relay installs stock - * node-pty from npm onto the host, where no pnpm patch reaches. Stock 1.1.0 leaks - * a pty fd on both Unix relay platforms, by two unrelated bugs on two code paths. - * - * Linux takes forkpty(), which has no atomic O_CLOEXEC, so every later child of - * the relay -- pty children, git helpers, probes, agent CLIs -- inherits each live - * master and keeps its /dev/pts device alive for the life of the relay (#8362). - * - * macOS takes pty_posix_spawn(), which opens up to three throwaway ptys to push - * the real master off fds 0-2 and then never closes them: the cleanup loop is - * `for (; count > 0; count--)`, but in any running process the first posix_openpt() - * already returns >= 2, so the loop breaks with count == 0 and its body never runs - * -- and where it does run it closes low_fds[count], never low_fds[0]. Measured on - * darwin-arm64: one orphaned /dev/ptmx fd per terminal, never returned. - * - * macOS does not inherit the master into spawned children today, but not because it - * is marked: FD_CLOEXEC is not set on it (`lsof +fg` shows R,W,NB, no CX). What - * closes it is POSIX_SPAWN_CLOEXEC_DEFAULT in pty_posix_spawn's spawn flags, an - * Apple-only flag that closes every fd in the child. That is one option away from - * gone -- setting uid/gid drops libuv back to fork()/exec(), which honors nothing - * but FD_CLOEXEC -- so the master is marked on the Apple path too, exactly as the - * app's pnpm patch marks it. Windows has no fds and is excluded. - * - * The compile it buys differs by platform. Linux relays already run node-gyp at - * install time (1.1.0 ships no linux prebuild), so this is a second compile on a - * path that already compiles. macOS runs the shipped darwin prebuild and has no - * build/ at all, so this is its first compile -- the price of the only fix there - * is, since the bug is in the source that prebuild was built from. - * - * Non-fatal by construction: the working build is moved aside before anything is - * touched and moved back on any failure, and a failed attempt drops a skip marker - * so the compile is attempted at most once per relay directory. - */ - -const { spawnSync } = require('node:child_process') -const { createHash } = require('node:crypto') -const { - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync -} = require('node:fs') -const { dirname, join, resolve } = require('node:path') - -const EXPECTED_NODE_PTY_VERSION = '1.1.0' -const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6' -const PATCHED_SOURCE_SHA256 = '3e6bc1a688aae187d231687130cfc0a11781c672f5f616d73183d471ee8ee65c' - -const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' -const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip' -const BACKUP_DIRNAME = '.orca-cloexec-prepatch-release' -// Under the caller's 240s SSH command timeout, so the rollback below still runs. -const REBUILD_TIMEOUT_MS = 200000 -const VERIFY_TIMEOUT_MS = 15000 - -const FORWARD_DECLARATION = [ - 'static int\npty_nonblock(int);\n', - 'static int\npty_nonblock(int);\n\nstatic int\npty_cloexec(int);\n' -] - -const DEFINITION = [ - `static int -pty_nonblock(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - if (flags == -1) return -1; - return fcntl(fd, F_SETFL, flags | O_NONBLOCK); -} -`, - `static int -pty_nonblock(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - if (flags == -1) return -1; - return fcntl(fd, F_SETFL, flags | O_NONBLOCK); -} - -/** - * Orca: close-on-exec FD - * - * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without - * FD_CLOEXEC is inherited by every later child of this process -- including - * later pty children -- which keeps its /dev/pts device and buffers alive long - * after its own session ends (#8362). - */ - -static int -pty_cloexec(int fd) { - int flags = fcntl(fd, F_GETFD); - if (flags == -1) return -1; - if (flags & FD_CLOEXEC) return 0; - return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); -} -` -] - -const FORKPTY_CALL_SITE = [ - ` default: - if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } - } -`, - ` default: - if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } - if (pty_cloexec(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); - } - } -` -] - -// Apple never reaches FORKPTY_CALL_SITE: `default:` sits in the `#else` arm of PtyFork's -// `#if defined(__APPLE__)`, so before this pair the asset patched nothing macOS executes. -const POSIX_SPAWN_CALL_SITE = [ - ` if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } -#else -`, - ` if (pty_nonblock(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); - } - if (pty_cloexec(master) == -1) { - throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); - } -#else -` -] - -// The throwaway ptys pty_posix_spawn opens to keep the real master off fds 0-2. Byte-identical to -// the app's pnpm patch, so both trees compile the same cleanup. -const LOW_FDS_DECLARATION = [ - ` int low_fds[3]; - size_t count = 0; -`, - ` int low_fds[3] = {-1, -1, -1}; - size_t count = 0; -` -] - -const LOW_FDS_CLEANUP = [ - ` for (; count > 0; count--) { - close(low_fds[count]); - } -`, - ` for (size_t i = 0; i <= count && i < 3; i++) { - if (low_fds[i] != -1) { - close(low_fds[i]); - } - } -` -] - -const REPLACEMENTS = [ - FORWARD_DECLARATION, - DEFINITION, - POSIX_SPAWN_CALL_SITE, - FORKPTY_CALL_SITE, - LOW_FDS_DECLARATION, - LOW_FDS_CLEANUP -] - -function sourceSha256(source) { - return createHash('sha256').update(source).digest('hex') -} - -function nodePtyDir(relayDir) { - return resolve(relayDir, 'node_modules', 'node-pty') -} - -function inspectNodePtyUnixSource(relayDir) { - const ptyDir = nodePtyDir(relayDir) - const sourcePath = join(ptyDir, 'src', 'unix', 'pty.cc') - const version = JSON.parse(readFileSync(join(ptyDir, 'package.json'), 'utf8')).version - if (version !== EXPECTED_NODE_PTY_VERSION) { - throw new Error(`Refusing to patch node-pty ${version}; expected ${EXPECTED_NODE_PTY_VERSION}`) - } - return { ptyDir, sourcePath, source: readFileSync(sourcePath, 'utf8') } -} - -function writeSourceAtomically(sourcePath, contents) { - const temporaryPath = `${sourcePath}.orca-patch-${process.pid}` - // Why: a terminated install must leave one of the two known source versions on disk. - try { - writeFileSync(temporaryPath, contents) - renameSync(temporaryPath, sourcePath) - } finally { - rmSync(temporaryPath, { force: true }) - } -} - -function rewriteSource(source, reverse) { - let rewritten = source - for (const [original, patched] of REPLACEMENTS) { - const from = reverse ? patched : original - const to = reverse ? original : patched - if (rewritten.split(from).length - 1 !== 1) { - throw new Error('Refusing to rewrite unexpected node-pty pty.cc source') - } - rewritten = rewritten.replace(from, to) - } - return rewritten -} - -/** True when the patch was applied, false when it was already installed. */ -function patchNodePtyMasterCloexecSource(relayDir = process.cwd()) { - const inspected = inspectNodePtyUnixSource(relayDir) - const hash = sourceSha256(inspected.source) - if (hash === PATCHED_SOURCE_SHA256) { - return false - } - if (hash !== ORIGINAL_SOURCE_SHA256) { - throw new Error('Refusing to patch unexpected node-pty pty.cc source') - } - writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, false)) - assertPatchedNodePtyMasterCloexecSource(relayDir) - return true -} - -function assertPatchedNodePtyMasterCloexecSource(relayDir = process.cwd()) { - const inspected = inspectNodePtyUnixSource(relayDir) - if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) { - throw new Error('node-pty pty master close-on-exec patch is not installed') - } -} - -function revertNodePtyMasterCloexecSource(relayDir = process.cwd()) { - const inspected = inspectNodePtyUnixSource(relayDir) - if (sourceSha256(inspected.source) === ORIGINAL_SOURCE_SHA256) { - return false - } - writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, true)) - return true -} - -function rebuildNodePty(relayDir) { - const result = spawnSync('npm', ['rebuild', '--ignore-scripts=false', 'node-pty'], { - cwd: relayDir, - encoding: 'utf8', - timeout: REBUILD_TIMEOUT_MS, - windowsHide: true - }) - if (result.error) { - throw new Error(`npm rebuild node-pty failed: ${result.error.message}`) - } - if (result.status !== 0) { - const tail = `${result.stdout || ''}${result.stderr || ''}`.trim().slice(-300) - throw new Error(`npm rebuild node-pty exited ${result.status ?? result.signal}: ${tail}`) - } -} - -// Why a child, for both scripts below: a bad build can abort the process on require, which would -// strand the moved-aside working build. Why each ends in a reachability check: a host that cannot -// show its fds says nothing, and an unobservable flag is not evidence the rebuild was wrong. -// -// Linux's leak is inheritance, so the observation is a later plain child's /proc/self/fd. -const VERIFY_INHERITANCE_SCRIPT = ` -const pty = require(process.argv[1]); -const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env -}); -const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l /proc/self/fd'], { encoding: 'utf8' }); -try { term.kill() } catch {} -const listing = probe.stdout || ''; -if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) } -console.log(listing.includes('ptmx') ? 'LEAKED' : 'ISOLATED'); -process.exit(0); -` - -// Apple's leak is self-held, not inherited, so the observation is this process's own fd table: -// N live ptys must account for exactly N /dev/ptmx rows. A stock build shows 2N -- the master plus -// the throwaway pty_posix_spawn opened and never closed. lsof, not /proc, because macOS has no -// /proc; a host without lsof cannot say, which is 'unverified', not a failed patch. -const VERIFY_SELF_FDS_SCRIPT = ` -const pty = require(process.argv[1]); -const terms = []; -for (let i = 0; i < 3; i++) { - terms.push(pty.spawn('/bin/sh', ['-c', 'sleep 30'], { - name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env - })); -} -const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'lsof -p ' + process.pid], { encoding: 'utf8', maxBuffer: 1 << 24 }); -for (const term of terms) { try { term.kill() } catch {} } -const rows = (probe.stdout || '').split('\\n').filter((line) => line.includes('/dev/ptmx')); -if (probe.status !== 0 || rows.length < terms.length) { console.log('UNVERIFIED'); process.exit(0) } -console.log(rows.length > terms.length ? 'LEAKED' : 'ISOLATED'); -process.exit(0); -` - -const LEAK_MESSAGE = { - darwin: 'rebuilt node-pty still leaks a throwaway pty fd per spawn', - linux: 'rebuilt node-pty still leaks the pty master into later children' -} - -/** 'isolated' when the platform's leak is gone, 'unverified' when the host cannot show it. */ -function verifyNoPtyFdLeak(relayDir, platform) { - const script = platform === 'darwin' ? VERIFY_SELF_FDS_SCRIPT : VERIFY_INHERITANCE_SCRIPT - const result = spawnSync(process.execPath, ['-e', script, nodePtyDir(relayDir)], { - cwd: relayDir, - encoding: 'utf8', - timeout: VERIFY_TIMEOUT_MS, - windowsHide: true - }) - const output = `${result.stdout || ''}` - if (result.status !== 0 || result.error) { - const tail = `${output}${result.stderr || ''}`.trim().slice(-300) - throw new Error( - `rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}` - ) - } - if (output.includes('LEAKED')) { - throw new Error(LEAK_MESSAGE[platform] || LEAK_MESSAGE.linux) - } - return output.includes('ISOLATED') ? 'isolated' : 'unverified' -} - -/** - * What gets moved aside before the compile, and where the compile writes. - * - * Linux ships no prebuild, so `build/Release` is both the working build and the compile's output, - * and moving it aside only arms the rollback. macOS runs `prebuilds/darwin-` and has no - * `build/` at all, so the compile writes a new `build/Release` -- which node-pty's loader checks - * ahead of `prebuilds`. Moving `prebuilds` aside does double duty there: it arms the rollback and - * it is what makes node-pty's install script fall through from "prebuild found" to `node-gyp - * rebuild`. Deliberately not `npm_config_build_from_source`, which deletes the prebuilds outright - * and would leave nothing to roll back to. - */ -function buildLayout(relayDir, platform, arch) { - const ptyDir = nodePtyDir(relayDir) - const compiledDir = join(ptyDir, 'build', 'Release') - if (platform === 'darwin') { - const prebuildsDir = join(ptyDir, 'prebuilds') - return { - compiledDir, - movedDir: prebuildsDir, - workingBuildPath: join(prebuildsDir, `darwin-${arch}`, 'pty.node'), - missingStatus: 'skipped:no-prebuild' - } - } - return { - compiledDir, - movedDir: compiledDir, - workingBuildPath: join(compiledDir, 'pty.node'), - missingStatus: 'skipped:no-compiled-build' - } -} - -function rollback(relayDir, layout, backupDir) { - rmSync(layout.compiledDir, { recursive: true, force: true }) - try { - revertNodePtyMasterCloexecSource(relayDir) - } catch { - // The build that is about to be restored predates the patch either way. - } - if (existsSync(backupDir)) { - mkdirSync(dirname(layout.movedDir), { recursive: true }) - renameSync(backupDir, layout.movedDir) - } -} - -/** - * Patch and rebuild the host's node-pty, or leave it exactly as found. - * Never throws: the caller is on the connect path and a leaky relay beats no relay. - */ -function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) { - const platform = options.platform || process.platform - const arch = options.arch || process.arch - const rebuild = options.rebuild || rebuildNodePty - const verify = options.verify || verifyNoPtyFdLeak - if (platform !== 'linux' && platform !== 'darwin') { - return 'skipped:unsupported-platform' - } - const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME) - if (existsSync(skipMarkerPath)) { - return 'skipped:earlier-attempt-failed' - } - const layout = buildLayout(relayDir, platform, arch) - const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME) - // A backup stranded by a connection that died mid-rebuild is stale by definition: - // whatever repaired node-pty since built from the source now on disk. - rmSync(backupDir, { recursive: true, force: true }) - - let inspected - try { - inspected = inspectNodePtyUnixSource(relayDir) - } catch (err) { - return `skipped:${err.message}` - } - const hash = sourceSha256(inspected.source) - if (hash === PATCHED_SOURCE_SHA256) { - return 'already-patched' - } - if (hash !== ORIGINAL_SOURCE_SHA256) { - return 'skipped:unexpected-source' - } - // Nothing to fall back on means the host runs neither a compile nor the prebuild - // this platform expects; rebuilding could only take away the artifact the probe - // just proved loadable. - if (!existsSync(layout.workingBuildPath)) { - return layout.missingStatus - } - - try { - renameSync(layout.movedDir, backupDir) - } catch (err) { - return `skipped:${err.message}` - } - try { - patchNodePtyMasterCloexecSource(relayDir) - rebuild(relayDir) - const verdict = verify(relayDir, platform) - // Discarded, not restored: a tree that gets published must hold no unpatched binary the - // loader could still fall back to. A later repair recompiles from the patched source. - rmSync(backupDir, { recursive: true, force: true }) - return verdict === 'isolated' ? 'patched' : 'patched-unverified' - } catch (err) { - rollback(relayDir, layout, backupDir) - // Bounded on purpose: one compile attempt per relay directory, never a retry loop. - try { - writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`) - } catch { - // A relay dir we cannot write to will fail the cheap checks above next time anyway. - } - return `failed:${err.message}` - } -} - -if (require.main === module) { - console.log(`${STATUS_PREFIX}${applyNodePtyMasterCloexecPatch()}`) -} - -module.exports = { - EXPECTED_NODE_PTY_VERSION, - ORIGINAL_SOURCE_SHA256, - PATCHED_SOURCE_SHA256, - SKIP_MARKER_FILENAME, - STATUS_PREFIX, - applyNodePtyMasterCloexecPatch, - assertPatchedNodePtyMasterCloexecSource, - patchNodePtyMasterCloexecSource, - revertNodePtyMasterCloexecSource -} diff --git a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs deleted file mode 100644 index 4c7b5b9939d6..000000000000 --- a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs +++ /dev/null @@ -1,362 +0,0 @@ -const { createHash } = require('node:crypto') -const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs') -const { join, resolve } = require('node:path') - -/** - * Release the ConPTY teardown handles a relay's npm-installed node-pty never releases. - * - * Two files, and the ORDER of one of the edits is the whole fix. - * - * `windowsPtyAgent.js` -- `kill()` flips `readable` on both sockets and destroys neither. - * `_cleanUpProcess` destroys `_outSocket`, so the conout handle comes back; nothing ever destroys - * `_inSocket`, and it wraps a real Windows named-pipe handle from `fs.openSync(term.conin, 'w')`. - * Every terminal leaks one File handle for the life of the host process. - * - * The obvious fix -- and the placement `config/patches/node-pty@1.1.0.patch` uses -- releases it at - * the TOP of the branch, before `_getConsoleProcessList()` forks and before the native kill. That is - * measurably worse than leaving the leak alone: teardown aborts partway, the forked console-list - * agent is never reaped, and both pipe handles stay alive instead of one. This asset releases it at - * the END of the branch instead, after the fork and the kill have already happened. - * - * Measured on a Windows SSH host, 20 spawn/kill cycles, handles bucketed by NT object type - * (identical numbers standalone and through a real relay). Every row is the NON-DLL branch, which - * is the branch a relay runs -- see the divergence note below for why that matters: - * - * published node-pty File +1/terminal, Process flat - * desktop patch placement File +2/terminal, Process +1/terminal <-- 3x WORSE - * released last (here) File flat, Process flat - * - * `windowsTerminal.js` carries the desktop's error-listener hunks verbatim. The conin listener is - * what keeps a pipe error retiring one terminal instead of the host -- its own comment names the - * failure mode: "Without a listener, Node promotes errors such as write EAGAIN to uncaughtException". - * It is not what fixes the leak (adding it changed nothing on its own), but it is the guard that - * makes destroying conin safe at all. - * - * Why this ships as a relay asset rather than only in config/patches/node-pty@1.1.0.patch: pnpm - * patches do not cross the SSH boundary -- a relay host runs the tree `npm install` put there. - * - * DELIBERATE DIVERGENCE FROM THE DESKTOP, AND WHY IT IS NOT A DESKTOP-TERMINAL BUG: the two hosts - * do not run the same branch of `kill()`. node-pty defaults `_useConptyDll` to false - * (`windowsPtyAgent.js`). Every desktop site that opens a terminal pane sets it true -- - * `local-pty-utils.ts` (two) and `native-pty-spawn.ts` -- as does the `windows-conpty-warmup.ts` - * warm-up, so all of those take the `else` branch, where UPSTREAM ALREADY destroys the input - * socket. The relay passes no such option (`src/relay/pty-handler.ts`), so it takes the - * `!useConptyDll` branch -- the one this asset and the desktop patch both edit. - * - * THE DESKTOP IS NOT ENTIRELY OFF THAT BRANCH. Two desktop sites omit the option and so run it - * too: the hidden rate-limit probes in `src/main/rate-limits/claude-pty.ts` and - * `codex-pty-rate-limit-probe.ts`. Both recur -- their fetchers poll -- and both tear down through - * `kill()`, so this hunk is live on the desktop, just never for a pane a user can see. Do not - * restate this as "the desktop never executes that branch": that sentence stood here for two - * revisions and is false. - * - * What the numbers above therefore do NOT cover: they were measured on relay-style spawn/kill - * cycles. Whether the early placement costs the same +2 File / +1 Process across a probe's - * lifecycle is UNMEASURED -- plausible, not established, and worth measuring before anyone quotes - * a desktop figure. What IS settled is the claim this comment replaced: that the desktop patch made - * every Windows user worse off ON EVERY TERMINAL. Terminals take the DLL branch, and the harness - * that produced that claim defaulted into the branch it was not trying to measure. - * - * The divergence is therefore about which branch each host runs for the workload that matters, not - * about a regression in the terminals users open. The test still pins it, because a future "sync - * the patches" would put the early placement onto the relay's branch, where it does cost +2 File - * and +1 Process per terminal. - * - * If you extend this enumeration, grep for `node-pty` rather than for a static import: those two - * probes were missed three times because they use `await import('node-pty')`. - * - * THE SELF-EXIT LEAK: FIXED FOR THE DESKTOP BY #18635, STILL LIVE ON A RELAY. A terminal that exits - * on its own is also torn down through `kill()` -- both hosts call `destroy()` on natural exit and - * `WindowsTerminal.destroy()` is `kill()` -- but the shell is already gone by then, so the ordering - * this asset relies on does not hold. Measured over 20 self-exit cycles on the NON-DLL branch: - * published +3 File/+1 Process per terminal, desktop patch placement +2/+1, this tree +2/+1. This - * asset does not close it. - * - * #18635 does, in `config/patches/node-pty@1.1.0.patch`: the baton outlives the shell so `PtyKill` - * still reaches `ClosePseudoConsole`, plus an unconditional conout dispose on the DLL branch. That - * fix does not reach a Windows relay, and no hunk in THIS file can carry it, because it is mostly - * NATIVE (`src/win/conpty.cc`) and this asset only rewrites `lib/*.js`. Three delivery paths exist - * and none currently covers Windows: - * - * - the pnpm patch does not cross the SSH boundary -- the remote `npm install` yields upstream's - * unpatched node-pty; - * - the orcad prebuild matrix now compiles win32 slots from patched source - * (`config/scripts/build-orcad-prebuilds.mjs`), but only standalone orcad consumes them; - * nothing ships them to a Windows relay host yet; - * - a relay asset CAN patch native source and rebuild on the host -- that is exactly what - * `node-pty-1.1.0-master-cloexec-patch.cjs` does -- but it returns - * `skipped:unsupported-platform` for anything but linux/darwin. Extending it to win32 means - * requiring an MSVC toolchain on the relay host, a far heavier precondition than on Linux, - * where node-gyp already runs at install time. - * - * So a Windows SSH relay still leaks a pseudoconsole per self-exiting terminal, and closing it is a - * DELIVERY problem, not another hunk here. Do not read #18635's flat self-exit relay numbers as - * covering deployed relays: they were measured against a locally rebuilt binary, so they describe - * the relay CODE PATH on a patched tree, not the tree a relay host actually installs. - */ - -const EXPECTED_NODE_PTY_VERSION = '1.1.0' - -const WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS = [ - [ - ' var parsedEnv = _this._parseEnv(env);\n // If the terminal is ready\n _this._isReady = false;\n // Functions that need to run after `ready` event is emitted.\n _this._deferreds = [];\n // Create new termal.\n', - ' var parsedEnv = _this._parseEnv(env);\n // If the terminal is ready\n _this._isReady = false;\n _this._killRequested = false;\n _this._killComplete = false;\n _this._isPipeReady = false;\n // Functions that need to run after `ready` event is emitted.\n _this._deferreds = [];\n // Create new termal.\n' - ], - [ - " _this._pid = _this._agent.innerPid;\n _this._fd = _this._agent.fd;\n _this._pty = _this._agent.pty;\n // The forked windows terminal is not available until `ready` event is\n // emitted.\n _this._socket.on('ready_datapipe', function () {\n // Run deferreds and set ready state once the first data event is received.\n _this._socket.once('data', function () {\n // Wait until the first data event is fired then we can run deferreds.\n if (!_this._isReady) {\n // Terminal is now ready and we can avoid having to defer method\n // calls.\n _this._isReady = true;\n", - " _this._pid = _this._agent.innerPid;\n _this._fd = _this._agent.fd;\n _this._pty = _this._agent.pty;\n // A pre-output teardown must still publish the actual pipe close.\n _this._socket.once('close', function () {\n if (_this._isPipeReady) {\n _this.emit('exit', _this._agent.exitCode);\n }\n _this._close();\n });\n // The forked windows terminal is not available until `ready` event is\n // emitted.\n _this._socket.on('ready_datapipe', function () {\n _this._isPipeReady = true;\n if (_this._killRequested) {\n _this.kill();\n return;\n }\n // Run deferreds and set ready state once the first data event is received.\n _this._socket.once('data', function () {\n // Wait until the first data event is fired then we can run deferreds.\n if (!_this._isReady && !_this._killRequested) {\n // Terminal is now ready and we can avoid having to defer method\n // calls.\n _this._isReady = true;\n" - ], - [ - " _this._deferreds = [];\n }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {\n _this.emit('exit', _this._agent.exitCode);\n _this._close();\n });\n });\n _this._file = file;\n _this._name = name;\n", - ' _this._deferreds = [];\n }\n });\n });\n _this._file = file;\n _this._name = name;\n' - ], - [ - " });\n };\n WindowsTerminal.prototype.destroy = function () {\n var _this = this;\n this._deferNoArgs(function () {\n _this.kill();\n });\n };\n WindowsTerminal.prototype.kill = function (signal) {\n var _this = this;\n this._deferNoArgs(function () {\n if (signal) {\n throw new Error('Signals not supported on windows.');\n }\n _this._close();\n _this._agent.kill();\n });\n };\n WindowsTerminal.prototype._deferNoArgs = function (deferredFn) {\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n", - " });\n };\n WindowsTerminal.prototype.destroy = function () {\n this.kill();\n };\n WindowsTerminal.prototype.kill = function (signal) {\n if (signal) {\n throw new Error('Signals not supported on windows.');\n }\n // Retire input now; native close requires the forwarding pipe, not first output.\n this._killRequested = true;\n this._deferreds = [];\n this._close();\n if (!this._isPipeReady || this._killComplete) {\n return;\n }\n this._agent.kill();\n this._killComplete = true;\n };\n WindowsTerminal.prototype._deferNoArgs = function (deferredFn) {\n if (this._killRequested) {\n return;\n }\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n" - ], - [ - ' });\n };\n WindowsTerminal.prototype._defer = function (deferredFn, arg) {\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n', - ' });\n };\n WindowsTerminal.prototype._defer = function (deferredFn, arg) {\n if (this._killRequested) {\n return;\n }\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n' - ] -] - -const PRECONNECT_AGENT_REPLACEMENTS = [ - [ - 'var utils_1 = require("./utils");', - 'var utils_1 = require("./utils");\nvar eventEmitter2_1 = require("./eventEmitter2");' - ], - [ - ' this._innerPid = 0;', - ' this._innerPid = 0;\n this._onProcessExit = new eventEmitter2_1.EventEmitter2();' - ], - [ - ' WindowsPtyAgent.prototype.kill = function () {', - ' Object.defineProperty(WindowsPtyAgent.prototype, "onProcessExit", {\n get: function () { return this._onProcessExit.event; },\n enumerable: false,\n configurable: true\n });\n Object.defineProperty(WindowsPtyAgent.prototype, "isConpty", {\n get: function () { return this._useConpty === true; },\n enumerable: false,\n configurable: true\n });\n WindowsPtyAgent.prototype.killAfterOutputClosed = function () {\n var _this = this;\n return this._conoutSocketWorker.disposeImmediately().then(function () { return _this.kill(); });\n };\n WindowsPtyAgent.prototype.kill = function () {' - ], - [ - " this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });\n }\n };", - " this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });\n }\n this._onProcessExit.fire(exitCode);\n };" - ] -] - -const PRECONNECT_TERMINAL_REPLACEMENTS = [ - [ - ' _this._isPipeReady = false;', - ' _this._isPipeReady = false;\n _this._exitEmitted = false;\n _this._preconnectCleanupRequested = false;' - ], - [ - " // A pre-output teardown must still publish the actual pipe close.\n _this._socket.once('close', function () {\n if (_this._isPipeReady) {\n _this.emit('exit', _this._agent.exitCode);\n }\n _this._close();\n });", - " _this._agent.onProcessExit(function () {\n if (_this._killRequested) _this._emitExit();\n });\n _this._socket.once('close', function () {\n if (_this._isPipeReady && !_this._preconnectCleanupRequested && (!_this._killRequested || !_this._agent.isConpty || _this._agent.exitCode !== undefined)) _this._emitExit();\n _this._close();\n if (_this._killRequested && !_this._isPipeReady) _this._killAfterOutputClosed();\n });" - ], - [ - ' if (!this._isPipeReady || this._killComplete) {\n return;\n }', - ' if (this._killComplete || this._preconnectCleanup) return;\n if (this._preconnectCleanupRequested || (!this._isPipeReady && this._socket.destroyed)) {\n this._killAfterOutputClosed();\n return;\n }\n if (!this._isPipeReady) return;' - ], - [ - ' WindowsTerminal.prototype._deferNoArgs =', - " WindowsTerminal.prototype._emitExit = function () {\n if (this._exitEmitted) return;\n this._exitEmitted = true;\n this.emit('exit', this._agent.exitCode);\n };\n WindowsTerminal.prototype._killAfterOutputClosed = function () {\n var _this = this;\n if (!this._agent.isConpty || this._killComplete || this._preconnectCleanup) return;\n this._preconnectCleanupRequested = true;\n this._preconnectCleanup = this._agent.killAfterOutputClosed().then(function () {\n _this._killComplete = true;\n if (_this._agent.exitCode !== undefined) _this._emitExit();\n }, function (error) {\n _this._preconnectCleanup = undefined;\n if (_this.listeners('error').length > 1) {\n _this.emit('error', error);\n }\n });\n if (this._agent.exitCode !== undefined) this._emitExit();\n };\n WindowsTerminal.prototype._deferNoArgs =" - ] -] - -const PRECONNECT_CONOUT_REPLACEMENTS = [ - [ - ' case 1 /* READY */:\n _this._onReady.fire();', - ' case 1 /* READY */:\n if (_this._termination) return;\n _this._onReady.fire();' - ], - [ - ' ConoutConnection.prototype.connectSocket = function (socket) {\n socket.connect', - ' ConoutConnection.prototype.disposeImmediately = function () {\n this._isDisposed = true;\n if (this._drainTimeout) clearTimeout(this._drainTimeout);\n return this._destroySocket();\n };\n ConoutConnection.prototype.connectSocket = function (socket) {\n if (this._termination) return;\n socket.connect' - ], - [ - ' ConoutConnection.prototype._destroySocket = function () {\n return __awaiter(this, void 0, void 0, function () {\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0: return [4 /*yield*/, this._worker.terminate()];\n case 1:\n _a.sent();\n return [2 /*return*/];\n }\n });\n });\n };\n', - ' ConoutConnection.prototype._destroySocket = function () {\n if (!this._termination) {\n this._termination = this._worker.terminate().then(function () { return undefined; });\n }\n return this._termination;\n };\n' - ] -] - -/** Each entry is one published file, its patched form, and the edits between them. */ -const PREVIOUS_PATCH_TARGETS = [ - { - relativePath: ['lib', 'windowsConoutConnection.js'], - originalSha256: '1440f70908fb1f55911ac8e936a1230f68a9c00c03096fcef6788eac6aad9d62', - patchedSha256: '37fab0688764326444509f7ffb24369f99fda4fbe29db72dccfad52c457ba244', - replacements: PRECONNECT_CONOUT_REPLACEMENTS - }, - { - relativePath: ['lib', 'windowsPtyAgent.js'], - originalSha256: '8636d16b38266112204061a22b135734177c242837982fd3a4055be726efa64a', - patchedSha256: '3c14daf8d0ec2d1e2d66435caa5fb2b629b230e237873594e623e79e6a7d1223', - previousPatchedSha256: '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280', - previousReplacements: PRECONNECT_AGENT_REPLACEMENTS, - replacements: [ - [ - ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n', - ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n // Orca: released AFTER the console-list fork and the native kill, not before them.\n // Destroying conin first aborts teardown partway -- measured on a Windows SSH relay\n // as +2 File and +1 Process handles per terminal, against +1 File unpatched.\n this._inSocket.destroy();\n' - ], - ...PRECONNECT_AGENT_REPLACEMENTS - ] - }, - { - relativePath: ['lib', 'windowsTerminal.js'], - originalSha256: 'c3a65716f53fed0135a8a633373d5f9c2ab092544d651f27ef0a67096dd3bcd9', - patchedSha256: '5dfeb1dda46645e1d77964ad4d07072b34f0a897dfc7e785137cc39e755b4641', - additionalPreviousVariants: [ - { - sha256: '3060c6514a8e9e3285f91b9b549930e7d25d59d4cf7e1ed3a25b9a680dd1ded5', - replacements: PRECONNECT_TERMINAL_REPLACEMENTS - }, - { - sha256: '598755ee75307d041a72cd7c7c4e12ae4a4bbf35eee19da67b42b61fdae0d4d6', - replacements: [ - [ - " _this._preconnectCleanup = undefined;\n _this.emit('error', error);", - " _this._preconnectCleanup = undefined;\n if (_this.listeners('error').length > 1) {\n _this.emit('error', error);\n }" - ], - [ - ' if (_this._preconnectCleanupRequested) _this._emitExit();', - ' if (_this._killRequested) _this._emitExit();' - ], - [ - ' if (_this._isPipeReady && !_this._preconnectCleanupRequested) _this._emitExit();', - ' if (_this._isPipeReady && !_this._preconnectCleanupRequested && (!_this._killRequested || !_this._agent.isConpty || _this._agent.exitCode !== undefined)) _this._emitExit();' - ] - ] - } - ], - previousPatchedSha256: '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', - previousReplacements: [ - ...WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS, - ...PRECONNECT_TERMINAL_REPLACEMENTS - ], - replacements: [ - [ - ' _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;', - " _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.\n _this._socket.on('error', function (err) {\n var code = err && err.code;\n // PTY output can report EPIPE before `_close()` wins the race.\n _this._close();\n if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {\n return;\n }\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (typeof code === 'string') {\n if (~code.indexOf('errno 5') || ~code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;" - ], - [ - " }\n });\n // Shutdown if `error` event is emitted.\n _this._socket.on('error', function (err) {\n // Close terminal session.\n _this._close();\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (err.code) {\n if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {", - " }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {" - ], - [ - ' _this._readable = true;\n _this._writable = true;\n _this._forwardEvents();\n return _this;', - " _this._readable = true;\n _this._writable = true;\n // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes\n // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.\n _this._agent.inSocket.on('error', function () {\n if (!_this._writable) {\n return;\n }\n _this._close();\n try {\n _this._agent.kill();\n }\n catch (_a) {\n // The failing pipe may have raced process exit; the terminal is already unwritable.\n }\n });\n _this._forwardEvents();\n return _this;" - ], - [ - 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map', - 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map\n' - ], - ...WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS, - ...PRECONNECT_TERMINAL_REPLACEMENTS - ] - } -] - -// Retire the entrypoint before removing the APIs it required. -const PATCH_TARGETS = [ - 'windowsTerminal.js', - 'windowsPtyAgent.js', - 'windowsConoutConnection.js' -].map((file) => { - const previous = PREVIOUS_PATCH_TARGETS.find((target) => target.relativePath.at(-1) === file) - const inverse = (replacements) => replacements.toReversed().map(([from, to]) => [to, from]) - const isConout = file === 'windowsConoutConnection.js' - const retiredReplacements = isConout ? previous.replacements : previous.previousReplacements - return { - relativePath: previous.relativePath, - originalSha256: previous.originalSha256, - patchedSha256: isConout ? previous.originalSha256 : previous.previousPatchedSha256, - replacements: isConout - ? [] - : previous.replacements.slice(0, previous.replacements.length - retiredReplacements.length), - previousPatchedSha256: previous.patchedSha256, - previousReplacements: inverse(retiredReplacements), - additionalPreviousVariants: previous.additionalPreviousVariants?.map((variant) => ({ - sha256: variant.sha256, - replacements: [...variant.replacements, ...inverse(retiredReplacements)] - })) - } -}) - -function inspectTarget(relayDir, target) { - const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty') - const packageJson = JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')) - if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) { - throw new Error( - `Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}` - ) - } - const filePath = join(nodePtyDir, ...target.relativePath) - return { filePath, source: readFileSync(filePath, 'utf8') } -} - -function assertPatchedNodePtyWindowsTeardown(relayDir = process.cwd()) { - for (const target of PATCH_TARGETS) { - const inspected = inspectTarget(relayDir, target) - if (sourceSha256(inspected.source) !== target.patchedSha256) { - throw new Error( - `node-pty ConPTY teardown release is not installed in ${target.relativePath.join('/')}` - ) - } - } -} - -function patchNodePtyWindowsTeardown(relayDir = process.cwd()) { - const pending = [] - for (const target of PATCH_TARGETS) { - const inspected = inspectTarget(relayDir, target) - const sourceHash = sourceSha256(inspected.source) - if (sourceHash === target.patchedSha256) { - continue - } - const replacements = - sourceHash === target.originalSha256 - ? target.replacements - : sourceHash === target.previousPatchedSha256 - ? target.previousReplacements - : target.additionalPreviousVariants?.find((entry) => entry.sha256 === sourceHash) - ?.replacements - if (!replacements) { - throw new Error( - `Refusing to patch unexpected node-pty source in ${target.relativePath.join('/')}` - ) - } - let patchedSource = inspected.source - for (const [from, to] of replacements) { - // Why the count check: an anchor that matched twice would patch the wrong site silently, and - // the hash below would then reject a tree this script had already rewritten. - if (patchedSource.split(from).length - 1 !== 1) { - throw new Error(`Refusing to patch ${target.relativePath.join('/')}; anchor is not unique`) - } - patchedSource = patchedSource.replace(from, to) - } - if (sourceSha256(patchedSource) !== target.patchedSha256) { - throw new Error( - `Refusing to install unexpected patched node-pty source in ${target.relativePath.join('/')}` - ) - } - pending.push({ filePath: inspected.filePath, patchedSource }) - } - for (const { filePath, patchedSource } of pending) { - const temporaryPath = `${filePath}.orca-patch-${process.pid}` - // Why: a terminated remote install must leave either known source version recoverable on reconnect. - try { - writeFileSync(temporaryPath, patchedSource) - renameSync(temporaryPath, filePath) - } finally { - rmSync(temporaryPath, { force: true }) - } - } - assertPatchedNodePtyWindowsTeardown(relayDir) -} - -function sourceSha256(source) { - return createHash('sha256').update(source).digest('hex') -} - -if (require.main === module) { - patchNodePtyWindowsTeardown() -} - -module.exports = { - assertPatchedNodePtyWindowsTeardown, - patchNodePtyWindowsTeardown -} diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index db244cabe768..c7e1d657a19f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -337,12 +337,12 @@ "invariant": "An ended tracked task stays pending until its wake-up and finishing turn end; missing wake-ups expire only after 60 idle seconds. Cancellation remains owned by the executing host, duplicate delivery cannot create a fresh completion veto, and fresh native rest can recover a lost finishing Stop.", "oracle": "Replay captured hook ordering through production HTTP entry points and native ready bytes through the actual automation observer. Check local/relay state, cancellation/new-turn fencing, fresh versus retained rest, bounded task/timer ownership, and zero global status reads during indexed runtime waits.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/tui-idle-hook-lane-runtime.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" ], "testFiles": [ "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts", "src/main/runtime/tui-idle-claude-task-wakeup.test.ts", - "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "src/main/runtime/tui-idle-hook-lane-runtime.test.ts", "src/shared/claude-owed-notification-resource-contract.test.ts", "src/shared/agent-hook-listener-claude-task-notification.test.ts", "src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts", @@ -358,7 +358,7 @@ ] }, { - "file": "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "file": "src/main/runtime/tui-idle-hook-lane-runtime.test.ts", "assertions": [ "reads only its pane with 512 unrelated agents, including an empty index: pending=%s" ] @@ -376,10 +376,10 @@ "date": "2026-10-06", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/tui-idle-hook-lane-runtime.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts", "result": "passed", "durationSeconds": 8.62, - "summary": "Exact stored command with repository Vitest configuration passes 148 tests across eight files including out-of-order launch/end/delivery controls. Includes real HTTP/relay ingress, 12 blank/padded Agent/Bash/Monitor IDs, captured-native-rest automation completion, duplicate/cancellation controls, owner/phase gates, timer/task bounds and indexed reads. Three upstream interrupt suites pass 28 more tests separately." + "summary": "Exact stored command with repository Vitest configuration passes 148 tests across eight files including out-of-order launch/end/delivery controls. Includes real HTTP/relay ingress, 12 blank/padded Agent/Bash/Monitor IDs, captured-native-rest automation completion, duplicate/cancellation controls, owner/phase gates, timer/task bounds and indexed reads. Three upstream interrupt suites pass 28 more tests separately. This dated receipt is unchanged; command now names the relocated test selector, not a new run. Original 2026-10-06 command: ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" } ], "runtimeBudget": { @@ -854,16 +854,13 @@ "invariant": "A fresh PTY launch of an agent with a trust preset pre-trusts its workspace root (for Codex, whose lookup keys on its start folder, the folder it starts in, including a floating terminal's) before the provider spawn, in both spawn builders, on the host that runs the agent: main for local and WSL launches, the SSH relay on its own disk for SSH launches. It never waits past the preset's deadline: 20 s for local Codex, the short budget for every other write, local or on the relay. A failed or abandoned write lets the launch proceed untrusted, so the agent asks. Restored or reattached panes, spawns with no launch command and the setting turned off are never trusted; for an agent whose trust on a home covers the folders below it (Claude, Copilot, Qoder), no writer stores a path that is a root, a home or a folder above one, and an unknown home writes nothing; Codex, Cursor and Antigravity, whose trust on a home covers only the home, trust it as they did before; and a WSL launch never writes the Windows home. A structured Codex chat, which has no PTY, pre-trusts its folder the same way when it is created, under the same setting.", "oracle": "A trust write held pending keeps each builder from returning until it settles. A never-settling local Codex write resolves the dispatcher only after the long deadline; a never-settling local Claude write resolves after the short budget with a named warning. Rejected and throwing writes, and a throwing guard, resolve with a warning. Restored panes, spawns without a command and the setting off make no dispatcher call; Codex in a floating terminal or a workspace subfolder is trusted at that folder, and every other preset at the workspace root. Creating a structured Codex chat trusts its folder before launch preparation, and nothing with the setting off. An SSH launch returns the relay field for every preset and calls no writer, and the relay's pty.spawn starts no process until that launch's trust call settles. A home, a folder above one, a root, a symlink to a home and a missing path that climbs back to the home through `..` write no Claude, Copilot or Qoder trust file, on this machine and on the relay, and the home writes Codex, Cursor and Antigravity trust.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" ], "testFiles": [ "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts", "src/main/agent-workspace-trust-spawn.test.ts", "src/main/agent-workspace-trust.test.ts", "src/main/execution-host-workspace-trust.test.ts", - "src/relay/agent-workspace-trust-spawn.test.ts", - "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", - "src/relay/pty-handler-agent-workspace-trust.test.ts", "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" ], "assertionRefs": [ @@ -904,24 +901,6 @@ "writes no %s trust when the host knows no home" ] }, - { - "file": "src/relay/agent-workspace-trust-spawn.test.ts", - "assertions": [ - "writes %s trust on the relay host's own disk", - "trusts the relay home for %s, whose trust there covers only the home", - "leaves Antigravity to ask, since its writer is unverified on SSH hosts" - ] - }, - { - "file": "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", - "assertions": ["skips %s trust and never fails the spawn"] - }, - { - "file": "src/relay/pty-handler-agent-workspace-trust.test.ts", - "assertions": [ - "writes the launch's trust with its final env before the agent's process starts" - ] - }, { "file": "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", "assertions": [ @@ -935,7 +914,7 @@ "date": "2026-09-29", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", "result": "passed", "durationSeconds": 4.16, "summary": "201 tests across eight suites pass, run through node_modules/.bin/vitest with a throwaway HOME. No native launch, PTY or live SSH host." @@ -1213,81 +1192,6 @@ "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": "relay-performance.ai-vault-ready-ownership", - "title": "AI Vault readiness releases canceled request owners", - "maturity": "experimental", - "protection": "partial", - "owner": "desktop-runtime", - "layer": "relay-service-client-contract", - "surfaces": ["AI Vault sidecar startup", "SSH title lookup cancellation"], - "platforms": ["macos", "linux", "windows"], - "providers": ["ssh"], - "coveredPlatforms": ["macos"], - "coveredProviders": ["ssh"], - "coverageNotes": "Production relay client with synthetic child events and real forced GC. Native remote process execution and live SSH remain untested. Local/daemon PTY, WSL shell selection and mobile terminal stream are unaffected.", - "motivatingLinks": ["https://github.com/stablyai/orca/issues/23024"], - "invariant": "Canceled unsent calls release request payloads before sidecar readiness, disposal releases all pending readiness owners, and live calls still start exactly once through cancellation and startup failures.", - "oracle": "Hold ready and cancel 1,000 title calls; require zero retained request arrays after GC and one child. Dispose before ready and require uncanceled payload collection and zero timers. Race ready with abort and spawn failure with a second lane; require only live calls delivered and successful results. Preserve existing restart and cancel-ack behavior.", - "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts" - ], - "testFiles": [ - "src/relay/ai-vault-service-ready-retention.test.ts", - "src/relay/ai-vault-service-client.test.ts", - "src/relay/ai-vault-service-restart-policy.test.ts", - "src/relay/ai-vault-service-spawn.test.ts", - "src/relay/ai-vault-handler.test.ts", - "src/shared/promise-settlement-waiters.test.ts" - ], - "assertionRefs": [ - { - "file": "src/relay/ai-vault-service-ready-retention.test.ts", - "assertions": [ - "releases canceled request payloads while readiness remains pending", - "releases uncanceled readiness payloads when the client is disposed", - "does not send a call canceled in the turn that readiness arrives", - "retries an unsent lane after spawn fails while the other lane starts a child" - ] - } - ], - "evidenceRuns": [ - { - "date": "2026-09-25", - "runner": "local", - "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts", - "result": "passed", - "durationSeconds": 0.616, - "summary": "38 tests across six suites passed, including real forced-GC retention and startup/cancel races." - } - ], - "runtimeBudget": { - "p95Seconds": 10, - "scope": "focused Node contract tests with forced GC" - }, - "flakeHistory": { - "status": "unknown", - "evidence": "Local runs passed; CI/soak history not collected." - }, - "redGreenEvidence": { - "status": "complete", - "evidence": "Baseline retains all 1,000 canceled arrays and the disposed uncanceled payload; both permanent regressions fail there and pass after the fix. Independent spawn-failure race failed on the initial prototype, passed on baseline and passes after correction." - }, - "performanceBudget": { - "required": true, - "evidence": "Synthetic cold-start churn retains 1,000 to zero canceled arrays; incremental heap 34,771,464 to 492,504 bytes. One shared startup reaction, no new polling, timers, process starts, timeout changes or wire fields." - }, - "promotionCriteria": [ - "Collect 100 consecutive CI passes or 14 days of soak history.", - "Exercise real SSH sidecar cancellation and restart on Linux and Windows." - ], - "knownGaps": [ - "Synthetic churn does not measure typical user frequency or UI latency.", - "Physical sidecar/native Windows/Linux and live SSH paths were not exercised." - ], - "demotionRule": "Keep experimental or demote if canceled payloads survive GC, disposal retains startup owners, or live lanes stall/duplicate after startup failure." - }, { "id": "runtime.connection-owned-host-status", "title": "Host status recovers with its owning connection", @@ -1692,7 +1596,6 @@ ], "demotionRule": "Keep experimental until soak and platform evidence support promotion; do not relax value or identity assertions to hide failures." }, - { "id": "workspace-session.ssh-host-partition-round-trip", "title": "An SSH workspace round-trips through its own partition without losing tabs, editor files or browser state", @@ -2561,99 +2464,6 @@ ], "demotionRule": "Keep experimental until CI soak is stable; investigate any budget failure without weakening transcript preservation." }, - { - "id": "ssh.localhost-terminal-agent-hooks", - "title": "Localhost SSH terminal and agent hooks reach the owning pane", - "maturity": "experimental", - "protection": "partial", - "owner": "terminal-runtime", - "layer": "electron-ssh-e2e", - "surfaces": ["SSH terminal", "remote agent status", "remote plugin installation"], - "platforms": ["macos", "linux", "windows"], - "providers": ["ssh"], - "coveredPlatforms": ["linux"], - "coveredProviders": ["ssh"], - "coverageNotes": "Ubuntu CI loopback sshd shares the runner filesystem. Fresh per-test repositories isolate retained relay workspace snapshots; existing Pi home supplies the documented bare-shell plugin prerequisite.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/19097"], - "invariant": "A localhost SSH terminal executes on the SSH host and routes authenticated hook status to its owning pane without treating idle keyboard input as agent interruption.", - "oracle": "Require terminal output markers, exported hook identity, actual OpenCode/Pi plugin files, and matching pane/worktree/connection hook events; Ctrl-C and Escape in an idle shell must not interrupt a hook-owned agent.", - "commands": [ - "gh run view 34045578306 --log", - "gh run view 34045975180 --log", - "gh run view 34046230389 --log", - "ORCA_E2E_SSH_LOCALHOST=1 ORCA_FEATURE_REMOTE_AGENT_HOOKS=1 pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1", - "node_modules/.bin/vitest run --config config/vitest.config.ts config/scripts/ssh-localhost-e2e-routing.test.mjs" - ], - "testFiles": [ - "tests/e2e/ssh-localhost.spec.ts", - "config/scripts/ssh-localhost-e2e-routing.test.mjs" - ], - "assertionRefs": [ - { - "file": "tests/e2e/ssh-localhost.spec.ts", - "assertions": ["routes a terminal and agent-hook status over localhost SSH"] - }, - { - "file": "config/scripts/ssh-localhost-e2e-routing.test.mjs", - "assertions": ["selects the localhost journey for its remote hook authorities"] - } - ], - "evidenceRuns": [ - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "command": "gh run view 34045578306 --log", - "result": "failed", - "summary": "Shared repository:2passed1failed, active pane PTY binding timed out amid old SSH target ownership conflicts.", - "durationSeconds": 150 - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "command": "gh run view 34045975180 --log", - "result": "passed", - "durationSeconds": 114, - "summary": "Fresh per-test repository:3passed,0skips0retries; original assertions retained." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "command": "gh run view 34046230389 --log", - "result": "passed", - "summary": "Normal selective workflow with isolated repository executed the localhost journey successfully; generic lane filtered it out.", - "durationSeconds": 36.4 - } - ], - "runtimeBudget": { - "p95Seconds": 1200, - "scope": "CI job timeout; measured p95 not established" - }, - "flakeHistory": { - "status": "soaking", - "evidence": "Single baseline passed, shared-path repetitions exposed state leakage; isolated-path3/3 and normal workflow passed. Long-term history missing." - }, - "redGreenEvidence": { - "status": "partial", - "evidence": "Same original scenario failed across shared-path repetitions and passed with unique paths; no application fault-mutation proof." - }, - "performanceBudget": { - "required": false, - "evidence": "Functional terminal and hook routing coverage, not a performance oracle." - }, - "promotionCriteria": [ - "Collect repeated scheduled Linux runs without unexplained failures.", - "Preserve all original terminal, environment, plugin-file, and hook-status assertions." - ], - "knownGaps": [ - "Different client profiles reopening one existing remote workspace can encounter old target-qualified PTY IDs; the fixture isolation does not fix that application behavior.", - "No macOS/Windows, remote network failure, folder-only, packaged, or mixed-version claim.", - "PR E2E is not part of required verify while broader reliability remains unresolved." - ], - "demotionRule": "Keep experimental on unexplained failures; do not mask them with retries, skips, or longer timeouts." - }, { "id": "terminal-output.prestarted-shell-snapshot-adoption", "title": "Prestarted shell adoption paints covered output once", @@ -3178,8 +2988,6 @@ "commands": [ "node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts", - "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "SKIP_BUILD=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec playwright test tests/e2e/ephemeral-vm-cleanup-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ @@ -3204,8 +3012,7 @@ "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts", "src/renderer/src/lib/provisioned-root-create-options.test.ts", "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts", - "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts", - "tests/e2e/ephemeral-vm-provisioned-root.spec.ts" + "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts" ], "assertionRefs": [ { @@ -3301,13 +3108,6 @@ "assertions": [ "the rendered runtime row stops a live cleanup process without an automatic deadline and a retry succeeds" ] - }, - { - "file": "tests/e2e/ephemeral-vm-provisioned-root.spec.ts", - "assertions": [ - "default-base create adopts the recipe checkout at the captured source commit", - "workspace removal invokes recipe destroy and removes the provider container" - ] } ], "evidenceRuns": [ @@ -3328,15 +3128,6 @@ "result": "passed", "durationSeconds": 3, "summary": "One hundred twenty-two focused store, recipe-process, lifecycle, cleanup-handler, rendered stop-action, retry-context, source-ref, adoption-integrity, resume-integrity, and provisioning-recovery tests passed." - }, - { - "date": "2026-08-14", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 22, - "summary": "The final-tree full-build run completed the Docker Linux SSH journey: default-base create at the captured source commit, provisioned-root adoption, terminal, workspace removal, destroy, and provider-container cleanup." } ], "runtimeBudget": { @@ -3396,10 +3187,9 @@ "invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Bash scalar and supported array PROMPT_COMMAND values preserve user bytes, hook order, and foreground exit status while emitting exactly one OSC 133 A/C/D lifecycle without syntax errors. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, proof-based recovery releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger that proof. The existing bounded compatibility timeout may enqueue input without proof, but must not publish readiness, lose or duplicate that input across reconnect, or execute it before the replacement shell can accept it.", "oracle": "Split Fish output immediately after the ready marker and after the following ESC introducer; local and daemon owners must emit the exact ESC [ ? 2004 h bytes and headless xterm must not render literal control text. Run the real generated wrappers under node-pty with disposable zsh, bash, and Fish startup files. Exercise Bash scalar and array PROMPT_COMMAND values with empty, repeated, escaped, whitespace, control-byte, odd-backslash, status-mutating, DEBUG-trap, zoxide, and RHEL-style hooks; require exact A,C,D:0,A,C,D:1,A,C ordering and byte preservation. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must use the same lossless scanner composition and release through the same prompt contract.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1" ], "testFiles": [ @@ -3417,9 +3207,6 @@ "src/main/providers/local-pty-shell-ready-startup-command.test.ts", "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts", "src/main/providers/local-pty-provider-shell-readiness.test.ts", - "src/relay/pty-shell-launch.test.ts", - "src/relay/pty-handler-startup-command-delivery.test.ts", - "tests/e2e/ssh-startup-exec-readiness.spec.ts", "tests/e2e/paired-startup-exec-readiness.spec.ts" ], "assertionRefs": [ @@ -3463,22 +3250,6 @@ "zsh configurations without a line-editor enable sequence retain the existing timeout backstop" ] }, - { - "file": "src/relay/pty-handler-startup-command-delivery.test.ts", - "assertions": [ - "the SSH relay owner strips startup identity and releases provider-delivered input through prompt readiness", - "renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness", - "normal renderer-owned ready markers pass through unchanged and retain no readiness state" - ] - }, - { - "file": "tests/e2e/ssh-startup-exec-readiness.spec.ts", - "assertions": [ - "a Docker Linux OpenSSH/Bash replacement shell retains one compatibility-fallback input across reconnect without executing it before the blocked replacement can accept it", - "the owning terminal handle and raw PTY identity remain singular and stable", - "painted output, authoritative terminal output, and the first interactive input each appear exactly once" - ] - }, { "file": "tests/e2e/paired-startup-exec-readiness.spec.ts", "assertions": [ @@ -3501,7 +3272,7 @@ "date": "2026-08-12", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", "result": "passed", "durationSeconds": 6, "summary": "473 tests passed with one local Fish skip, including every-boundary scanner composition, real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles." @@ -3510,20 +3281,11 @@ "date": "2026-08-15", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts", "result": "passed", "durationSeconds": 6, "summary": "233 tests passed with one local Fish skip, including exact Bash PROMPT_COMMAND byte/status/lifecycle composition across daemon, local, and relay owners." }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "linux", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 17, - "summary": "The live OpenSSH/Bash relay journey crossed the bounded compatibility fallback while rc-file exec was blocked, proved one queued command echo with no command execution, then passed a disconnect/reconnect with exact-once startup output/input and stable pane plus owner PTY identities." - }, { "date": "2026-08-12", "runner": "local", @@ -3761,23 +3523,13 @@ "invariant": "A non-empty SSH file stream that produces no valid frame for 30 seconds must cancel at its authoritative stream reader and release all local lifecycle state. System suspend is sticky across metadata and subscription races, resume grants every still-live stream one fresh inactivity window, one failing consumer cannot block the others, and the sole Electron bridge survives a vetoed before-quit without retaining a per-stream Electron listener.", "oracle": "Publish suspend before stream metadata resolves, jump wall time by one hour, and require the late-subscribing stream to remain pending until resume grants a fresh 30-second window. Deliver a valid final chunk and end frame, require exact content, then publish another resume and require zero timers, multiplexer listeners, or renewed work; separately require stalled cancellation at 30 seconds, atomic state replay, failure-isolated fanout, and bridge disposal only after the committed will-quit gate.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2" + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2" ], "testFiles": [ - "src/main/providers/ssh-filesystem-provider-stream.test.ts", "src/main/system-resume-broadcast.test.ts", "src/main/system-power-lifecycle.test.ts" ], "assertionRefs": [ - { - "file": "src/main/providers/ssh-filesystem-provider-stream.test.ts", - "assertions": [ - "cancels and cleans up a stream that stalls after metadata", - "keeps a long stream alive while chunks continue arriving", - "grants an active stream a fresh inactivity window after system resume", - "keeps metadata received during suspend paused until resume" - ] - }, { "file": "src/main/system-resume-broadcast.test.ts", "assertions": ["publishes suspend and resume to main-process lifecycle consumers"] @@ -3797,9 +3549,9 @@ "runner": "local", "platform": "macos", "result": "passed", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2", "durationSeconds": 8.141, - "summary": "The retained behavioral cohort passed 25 tests after removing source-only ordering tests. Historical pre-removal receipts are preserved verbatim with their originally executed commands: [{\"date\":\"2026-08-01\",\"runner\":\"local\",\"platform\":\"macos\",\"command\":\"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts src/main/startup/desktop-startup-ordering.test.ts --reporter=dot\",\"result\":\"passed\",\"durationSeconds\":1.6,\"summary\":\"Four focused files and 33 tests passed, including stalled cancellation, active progress, late metadata replay, failure isolation, committed-quit bridge lifetime, and post-settlement cleanup.\"}] No new native desktop or hosted run is claimed." + "summary": "The retained behavioral cohort passed 25 tests after removing source-only ordering tests. Historical pre-removal receipts are preserved verbatim with their originally executed commands: [{\"date\":\"2026-08-01\",\"runner\":\"local\",\"platform\":\"macos\",\"command\":\"pnpm exec vitest run --config config/vitest.config.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts src/main/startup/desktop-startup-ordering.test.ts --reporter=dot\",\"result\":\"passed\",\"durationSeconds\":1.6,\"summary\":\"Four focused files and 33 tests passed, including stalled cancellation, active progress, late metadata replay, failure isolation, committed-quit bridge lifetime, and post-settlement cleanup.\"}] No new native desktop or hosted run is claimed." } ], "runtimeBudget": { @@ -3856,22 +3608,15 @@ "invariant": "A first-install relay transfer must complete in an attempt-owned fixed staging slot before acquiring the shared version install lock. Reservation, promotion, confirmed cleanup, and stale recovery must reject path replacement, persisted-identity mismatch, POSIX symlinks, and Windows reparse points. A newly acquired install lock atomically records the execution host's boot identity; only a verified identity change or the existing stale-age proof may replace it, while legacy, missing, malformed, and unreadable identity state must retain the conservative stale fallback. Recovery examines only eight fixed slot/claim/delete names and removes at most one stale valid stage per call; eight unclaimable states fail with an explicit manual-recovery message. Split-SFTP hosts must prove the stage identity on the exact transfer session, only payload contents may be promoted under the shared lock, and cancellation must boundedly join SFTP, stream, local file-handle, and transfer settlement.", "oracle": "Pause a real ssh2 SFTP relay.js write after one remotely acknowledged chunk, prove the remote file is partial, abort the live transfer, and require no shared .install-lock, leaked local descriptor, or foreign-process termination. Separately inject two unconfirmed cancellations, require an independent deployment to install, launch, answer relay RPC, and read a real repository HEAD. Keep a fresh install lock on the current POSIX or Windows boot and require takeover to fail; replace its bounded identity with a prior-boot value and race concurrent recoverers, requiring exactly one atomic winner, a current successor identity, and no tombstone residue; omit the marker and require the legacy lock to remain fenced. Replace one retained fixed slot with an old-mtime same-owner directory while preserving the original, add a fixed-slot POSIX symlink, and require installed-path recovery to skip both while reclaiming a valid stale slot behind them. Add 15,197 unrelated relay-shaped names and run the real version GC, requiring bounded stdout and no removal. Unit and wire contracts cover exact POSIX and native PowerShell 0/1/7/8/9+ quota behavior, no-follow identity fencing, payload symlink/reparse rejection, one-item repeated draining, zero lock acquisition before upload settlement, joined transfer/channel teardown including never-settling failures, SFTP redirection, package.json namespace ownership, promotion only after the lock, cross-version isolation, and system-SSH behavior.", "commands": [ - "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts", - "ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST= ORCA_REVIEW_SSH_IMAGE= ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose" + "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts" ], "testFiles": [ "src/main/ssh/sftp-upload.test.ts", "src/main/ssh/ssh-file-transfer-abort.test.ts", - "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts", - "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts", - "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts", "src/main/ssh/ssh-relay-install-namespace.test.ts", - "src/main/ssh/ssh-relay-upload-stage-commands.test.ts", "src/main/ssh/sftp-namespace-resolution.test.ts", "src/main/ssh/ssh-connection-sftp-wire.test.ts", - "src/main/ssh/ssh-remote-commands.test.ts", - "src/main/ssh/ssh-relay-cross-version-isolation.test.ts", - "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts" + "src/main/ssh/ssh-remote-commands.test.ts" ], "assertionRefs": [ { @@ -3885,25 +3630,6 @@ "marks transfer teardown unconfirmed when close wins but the transfer never settles" ] }, - { - "file": "src/main/ssh/ssh-relay-upload-stage-commands.test.ts", - "assertions": [ - "bounds reservation at 0, 1, 7, 8, and 9+ entries on POSIX and native PowerShell", - "rejects same-path replacement, symlink, reparse, and identity substitution before promotion or deletion", - "reclaims at most one valid stale fixed slot and progresses across repeated deployments" - ] - }, - { - "file": "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts", - "assertions": [ - "waits for a deferred SFTP upload before acquiring the install lock", - "drops only its stage when a sibling finishes before the locked re-probe", - "recovers one fixed stale stage before a fresh upload", - "launches before bounded installed-path recovery", - "never enumerates arbitrary stage paths during installation", - "retries immediately after an unconfirmed upload termination instead of waiting on a fresh install lock" - ] - }, { "file": "src/main/ssh/ssh-remote-commands.test.ts", "assertions": [ @@ -3912,25 +3638,6 @@ "lets only one PowerShell caller acquire a legacy-visible lock", "keeps current and legacy fresh locks fenced while one concurrent caller replaces a previous-boot lock" ] - }, - { - "file": "src/main/ssh/ssh-relay-cross-version-isolation.test.ts", - "assertions": ["a v2 deploy never references the v1 install dir or v1 socket path"] - }, - { - "file": "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts", - "assertions": [ - "redirects every first-install artifact transfer while shell commands stay canonical", - "leaves system-SSH connections unmapped and unprobed" - ] - }, - { - "file": "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts", - "assertions": [ - "replaces a fresh previous-boot install lock over live ssh2 while preserving promoted payload and leaving no tombstone", - "aborts a live SFTP upload after remote bytes arrive without creating the shared lock", - "recovers cancellation with bounded safe reclamation and bounded real version GC" - ] } ], "evidenceRuns": [ @@ -3938,19 +3645,10 @@ "date": "2026-07-31", "runner": "local", "platform": "macos", - "command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts", + "command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts", "result": "passed", "durationSeconds": 57.9, "summary": "Eleven focused files passed 152 tests with 3 platform skips using exact POSIX sh, native ARM macOS PowerShell 7.6.4, real ssh2 split-SFTP wire sessions, fixed-slot identity races, bounded recovery, joined cancellation teardown, and cross-version isolation." - }, - { - "date": "2026-07-31", - "runner": "local", - "platform": "linux", - "command": "ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST= ORCA_REVIEW_SSH_IMAGE= ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose", - "result": "passed", - "durationSeconds": 12.58, - "summary": "A throwaway linux-arm64 Docker sshd acknowledged 65,536 of 837,401 relay.js bytes before live abort with no shared lock and preserved the foreign sleep sentinel. Two injected unconfirmed stages remained pre-lock; retry launched the relay, answered RPC, and read a real Git HEAD. Installed recovery preserved a same-owner identity-mismatched replacement, its original, a POSIX symlink, and its foreign target while draining one valid stale slot behind them. Real version GC retained all 15,197 unrelated names, emitted 25 bytes, and completed in 25 ms." } ], "runtimeBudget": { @@ -4567,7 +4265,6 @@ ], "demotionRule": "Keep experimental or demote if optimization closes an established client, a gate reopens on ambiguous authority, a mutation is replayed, cleanup is lost, or eligible idle hosts starve." }, - { "id": "git-worktree.refresh-event-semantics", "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", @@ -4600,8 +4297,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/watched-worktree-catalog-notification.test.ts src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts src/main/window/attach-main-window-services.test.ts", "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-external-worktree-discovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot" ], "testFiles": [ "src/main/ipc/worktree-base-directory-event-filter.test.ts", @@ -4622,8 +4318,7 @@ "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "tests/e2e/ssh-docker-relay-perf.spec.ts" + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts" ], "assertionRefs": [ { @@ -4771,13 +4466,6 @@ "one coordinator aggregate maps to one typed product event with queue and provider percentiles", "adapter failure is swallowed before it can reach recovery" ] - }, - { - "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", - "assertions": [ - "repo and worktree hydration use the exact direct SSH host and complete provider authority", - "terminal input remains live after disconnect/reconnect and writes a proof file visible inside the Linux target" - ] } ], "evidenceRuns": [ @@ -4808,15 +4496,6 @@ "durationSeconds": 3.45, "summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests." }, - { - "date": "2026-07-27", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 66, - "summary": "Four Electron Docker SSH tests passed: two typing/performance paths, one concurrent file/Git load path, and exact-authority disconnect/reconnect with a container-visible remote proof file." - }, { "date": "2026-08-26", "runner": "local", @@ -7382,9 +7061,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-webcontents-registry.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-persisted-worker-egress.electron.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-tcp-egress.electron.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts", - "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/local-ssh-browser-routing.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3", - "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3" + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts" ], "testFiles": [ "src/main/browser/browser-route-identity.test.ts", @@ -7398,9 +7075,7 @@ "src/main/browser/browser-route-webcontents-registry.test.ts", "src/main/browser/browser-session-registry.test.ts", "src/main/browser/browser-session-startup.test.ts", - "src/main/window/createMainWindow.test.ts", - "tests/e2e/local-ssh-browser-routing.spec.ts", - "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" + "src/main/window/createMainWindow.test.ts" ], "assertionRefs": [ { @@ -7488,20 +7163,6 @@ "a live route partition may attach only the normalized blank document", "an arbitrary URL cannot be the initial route-partition document" ] - }, - { - "file": "tests/e2e/local-ssh-browser-routing.spec.ts", - "assertions": [ - "a remote-only origin renders through direct SSH routing; cookies survive transport recovery", - "unavailable SSH hosts prevent premature webview attachment and offer a working explicit local escape hatch", - "real AllowTcpForwarding refusal is classified and Try anyway preserves the SSH route" - ] - }, - { - "file": "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts", - "assertions": [ - "paired client-hosted browser pages render an SSH-only origin, preserve cookies and retire superseded route pages across a real transport drop" - ] } ], "evidenceRuns": [ @@ -7540,24 +7201,6 @@ "result": "passed", "durationSeconds": 0.5, "summary": "Six files passed 150 opaque identity, durable collision binding, bounded partition/page, proxy-before-allowlist, policy reuse, profile startup, and blank-only attach tests." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/local-ssh-browser-routing.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3", - "durationSeconds": 252, - "summary": "Nine direct SSH cases passed: three repetitions each of routing/reconnect, unavailable-host local escape, and real TCP-forwarding refusal. Run 34040309638, head 259a5f6; unchanged tests and timeouts, zero skips or retries." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3", - "durationSeconds": 138, - "summary": "Three paired client-hosted reconnect cases passed with remote-only origin and cookie-preservation assertions. Run 34040309638, head 259a5f6; unchanged tests and timeouts, zero skips or retries." } ], "runtimeBudget": { @@ -9838,7 +9481,7 @@ "invariant": "PreCompact is never registered and may never move a pane, because it fires before the compact is validated and an aborted compact emits it alone. Only a manual PostCompact may mark done, and it does so as a session boundary so completion-reactive consumers stay out of it; an automatic PostCompact claims nothing and writes no compact-scoped state, since its turn resumes and emits its own Stop. A completion either CLEARS a row or publishes nothing at all: it never creates a row, so a retired pane cannot be resurrected, and it never restates one it cannot clear, because a restated row loses restoredUnconfirmed and restarts the staleness clock. A manual /compact only completes at an idle prompt, so it retires a subagent that exists ONLY as a disk snapshot; live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane at working. A completion requires a valid provider prompt UUID, matches the pane owner by connection and provider session -- except on a restored row, which carries the previous connection and may carry no session at all -- and is applied at most once per compact. Ownership is enforced by the client that owns pane identity; a relay forwards and defers, so a cold relay cache cannot swallow the clearing event. A relay predating this contract forwards its own mapping, so ingest drops automatic envelopes, stamps the boundary on manual ones, carries the summarized turn label onto a promptless completion with or without a trigger, and classifies a trigger-stripped replay by payload state while still enforcing ownership. Kimi and other Claude-compatible vendors remain fail-closed.", "oracle": "Replay the measured Claude compact streams through a harness gated on the real CLAUDE_EVENTS, plus local HTTP, relay, SSH, and persisted-restore transports. Require the successful manual stream to end done with sessionBoundary set, and require the aborted stream (PreCompact alone) to leave both an idle and a working pane exactly as it found them. Drive the restart shape end to end over loopback HTTP from a hydrated row: a pane held only by a snapshot child must reach done with an empty roster, while a pane a runtime child still owns must emit NOTHING and keep its row object identical, so receivedAt and restoredUnconfirmed both survive. Deliver the restored-row cases with the hydrated row still cached at completion time and assert the summarizer published nothing, so the restored-row branch is the branch under test. Require rejection of an automatic completion, a completion with absent prompt UUID, a foreign provider session, a duplicate, and a completion for a pane with no cached row; require an automatic completion to leave the consumed-compact map untouched. Require a relay with a COLD cache to forward the completion while the client still refuses to mint a row it never had. Require ingest to drop legacy automatic envelopes, stamp the boundary on legacy manual ones, carry the summarized turn label onto a promptless completion with and without a trigger, and leave a FINISHED pane finished when a trigger-stripped working replay arrives. Require the consumed-compact map to be empty after each pane-scoped teardown route and to follow a pane move. Assert registration membership, emitted event order, and the authoritative lastStatusByPaneKey snapshot.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts --reporter=dot", "Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both" ], "testFiles": [ @@ -9850,8 +9493,7 @@ "src/shared/agent-hook-relay.test.ts", "src/wsl-guest/agent-hook-server.test.ts", "src/main/agent-hooks/server-claude-normalization.test.ts", - "src/main/agent-hooks/server-last-status-write.test.ts", - "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts" + "src/main/agent-hooks/server-last-status-write.test.ts" ], "assertionRefs": [ { @@ -9883,12 +9525,6 @@ { "file": "src/main/agent-hooks/manual-compact-status-cleanup.test.ts", "assertions": ["pane, tab, and server cleanup revoke compact completion authority"] - }, - { - "file": "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts", - "assertions": [ - "SSH stamps the receiving connection and forwards source, prompt UUID, session, and manual trigger" - ] } ], "evidenceRuns": [ @@ -9896,7 +9532,7 @@ "date": "2026-08-18", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts --reporter=dot", "result": "passed", "durationSeconds": 3.28, "summary": "Ten files and 116 tests passed across registration, listener, local HTTP, relay, SSH, persistence, and cleanup. Reverting only the four source files to the merge base first turned 12 of 21 registration-gated tests and 8 of 12 relay/ingest tests red, so the suite observes its own failure rather than passing over unreachable code. Every assertion was additionally mutation-tested: 18 of 18 targeted mutations -- including reaping runtime children as well as snapshot ones, restating a pane live evidence still holds, re-enforcing compact ownership on the relay, and dropping the consumed-compact map from any one teardown route -- are caught." @@ -10335,15 +9971,14 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/remote-agent-session-focus-authority.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ "src/main/providers/pty-process-inspection.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-router.test.ts", "src/main/daemon/degraded-daemon-pty-provider.test.ts", - "src/relay/pty-handler-spawn-admission.test.ts", "src/main/runtime/orca-runtime.test.ts", "tests/e2e/remote-agent-completion-authority.unit.test.ts", "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", @@ -10400,10 +10035,6 @@ "completion inspection rejects an unmapped session instead of borrowing the local fallback" ] }, - { - "file": "src/relay/pty-handler-spawn-admission.test.ts", - "assertions": ["strict relay inspection rejects a missing PTY"] - }, { "file": "tests/e2e/remote-agent-completion-authority.unit.test.ts", "assertions": [ @@ -10585,7 +10216,7 @@ "date": "2026-07-23", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts", "result": "passed", "durationSeconds": 11.96, "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane." @@ -11034,7 +10665,7 @@ "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.", "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", @@ -11043,8 +10674,7 @@ "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/main/ipc/pty-applied-size-reporting.test.ts", "src/main/providers/ssh-pty-provider.test.ts", - "src/main/runtime/orca-runtime.test.ts", - "src/relay/pty-handler-output-streaming.test.ts" + "src/main/runtime/orca-runtime.test.ts" ], "assertionRefs": [ { @@ -11098,13 +10728,6 @@ "the headless mirror resizes after an accepted desktop PTY resize (#7192)", "headless mirror resizes are ordered behind queued PTY writes (#7192)" ] - }, - { - "file": "src/relay/pty-handler-output-streaming.test.ts", - "assertions": [ - "relay readback reports the grid actually applied by node-pty", - "missing relay PTYs return an unverified null size" - ] } ], "evidenceRuns": [ @@ -11112,7 +10735,7 @@ "date": "2026-07-21", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts", "result": "passed", "durationSeconds": 18.54, "summary": "8 test files passed, 1,272 tests passed on the PR branch." @@ -12560,7 +12183,7 @@ "invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.", "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, bounds scanner-service calls at sixteen, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts --maxWorkers=2 --reporter=dot" ], "testFiles": [ @@ -12572,8 +12195,7 @@ "src/main/ai-vault/session-title-file-reader.test.ts", "src/main/ai-vault/session-parse-cache-persistence.test.ts", "src/main/ipc/ai-vault.test.ts", - "src/main/runtime/rpc/methods/ai-vault.test.ts", - "src/relay/ai-vault-handler.test.ts" + "src/main/runtime/rpc/methods/ai-vault.test.ts" ], "assertionRefs": [ { @@ -12639,7 +12261,7 @@ "date": "2026-10-02", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts", "result": "passed", "durationSeconds": 5.3, "summary": "The focused run passed 138 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, per-lane scanner-service lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." @@ -12799,27 +12421,20 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts src/main/ssh/ssh-relay-session-model-migration.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-recovery-interleavings.test.ts src/relay/relay-pty-source-recovery-completion.test.ts src/relay/relay-pty-source-restore-retry.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/dispatcher.test.ts src/relay/pty-handler-source-publication.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/dispatcher.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-legacy-projection.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", - "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/relay/ssh-pty-consumer-session-adapter.test.ts src/main/persistence-ssh-targets-and-pane-keys.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts --reporter=dot", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", + "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/persistence-ssh-targets-and-pane-keys.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts --reporter=dot" // Historical evidence commands; the override is ignored after the always-on transition. - "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/ipc/pty-renderer-inflight-credit.test.ts", @@ -12837,27 +12452,8 @@ "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts", "src/main/providers/ssh-pty-provider-exit-race.test.ts", "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", - "src/main/ssh/ssh-relay-session.test.ts", - "src/main/ssh/ssh-relay-session-data-delivery.test.ts", - "src/main/ssh/ssh-relay-session-model-migration.test.ts", - "src/main/ssh/ssh-relay-session-recovery-races.test.ts", - "src/main/ssh/ssh-relay-session-incarnation.test.ts", - "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts", - "src/main/ssh/ssh-relay-session-terminal-error.test.ts", - "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts", - "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts", "src/wsl-guest/protocol-backpressure.test.ts", - "src/relay/git-response-stream-ownership.test.ts", - "src/relay/pty-handler-output-streaming.test.ts", - "src/relay/pty-source-credit-ledger.test.ts", - "src/relay/pty-source-credit-scheduler.test.ts", - "src/relay/relay-pty-source-publication.test.ts", - "src/relay/relay-pty-source-recovery-interleavings.test.ts", - "src/relay/relay-pty-source-recovery-completion.test.ts", - "src/relay/relay-pty-source-restore-retry.test.ts", "src/wsl-guest/dispatcher.test.ts", - "src/relay/pty-handler-source-publication.test.ts", - "src/relay/ssh-pty-source-credit-adapter.test.ts", "src/shared/pty-consumer-session.test.ts", "src/main/ipc/ssh-pty-model-admission.test.ts", "src/main/ipc/ssh-pty-output-intake.test.ts", @@ -12870,11 +12466,9 @@ "src/main/ssh/ssh-multiplexer-transport-writer.test.ts", "src/main/ssh/ssh-channel-multiplexer.test.ts", "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts", - "src/main/ssh/ssh-relay-deploy.test.ts", "src/main/persistence-ssh-targets-and-pane-keys.test.ts", "src/wsl-guest/dispatcher-client-writer.test.ts", - "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts", - "tests/e2e/ssh-docker-relay-perf.spec.ts" + "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts" ], "assertionRefs": [ { @@ -12969,99 +12563,18 @@ "source-credit restore-required generic reattach fails as expired instead of returning an outputless live PTY" ] }, - { - "file": "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts", - "assertions": [ - "fifty reattaches use at most eight workers and healthy siblings finish before one slow and one failed PTY", - "initial connection and automatic reconnect both offer source credit" - ] - }, - { - "file": "src/main/ssh/ssh-relay-session.test.ts", - "assertions": [ - "each transient PTY failure is retried once without tearing down successful provider registration" - ] - }, { "file": "src/main/persistence-ssh-targets-and-pane-keys.test.ts", "assertions": [ "retired per-target source-credit selections are removed during normalization" ] }, - { - "file": "src/main/ssh/ssh-relay-session-model-migration.test.ts", - "assertions": [ - "overlapping reconnect attaches remain blocked behind the old-generation per-PTY model fence and receive only the post-completion exact checkpoint", - "stale-owner retry retains the old-generation fence through raw completion, then requests checkpoint-unavailable restore" - ] - }, - { - "file": "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts", - "assertions": [ - "fragmented recovery is bounded per PTY and per session in source units, charged bytes, and frames", - "UTF-16 string storage and record overhead are charged before aggregate admission" - ] - }, - { - "file": "src/relay/pty-source-credit-ledger.test.ts", - "assertions": [ - "an exact early cumulative ACK reserves eligibility without advancing credit before send settlement and survives only an exact same-token retry", - "source retention charges max UTF-8 or UTF-16 storage plus one record", - "fragmented multi-PTY source, charged-byte, and frame caps bind independently" - ] - }, - { - "file": "src/relay/pty-source-credit-scheduler.test.ts", - "assertions": [ - "one rejecting delivery is removed after one reservation attempt while prior and later peer reservations remain committed without head-of-line blocking" - ] - }, - { - "file": "src/relay/git-response-stream-ownership.test.ts", - "assertions": [ - "an 8-byte encoded producer capacity emits exactly two 6-byte payload chunks and one end call while the first bulk write is saturated" - ] - }, - { - "file": "src/relay/pty-handler-output-streaming.test.ts", - "assertions": [ - "a fresh plain pending entry after transformed source-only output omits inherited rawLength and completes in exactly three admission calls" - ] - }, { "file": "src/wsl-guest/dispatcher.test.ts", "assertions": [ "a saturated legacy primary remains required backpressure and is never detached as an additional subscriber" ] }, - { - "file": "src/relay/pty-handler-source-publication.test.ts", - "assertions": [ - "immutable spawn and attach activation identity settles before the first source frame in the response/data interleaving", - "one saturated additional subscriber is bounded and detached without pausing the native PTY or negotiated owner" - ] - }, - { - "file": "src/relay/relay-pty-source-recovery-interleavings.test.ts", - "assertions": [ - "failed exit publication retains sealed delivery for late cumulative ACK and exact token recovery", - "exact owner recovery republishes exit without reopening source admission", - "a failed recovery-completion frame rolls back its fence and the next exact owner republishes it before live admission", - "failed activation response retains private retry authority, and exact retry clears activation only after successful settlement" - ] - }, - { - "file": "src/relay/relay-pty-source-recovery-completion.test.ts", - "assertions": [ - "unadmitted recovery completion retries once writer capacity returns, stays single-flight, and releases its bounded capacity listener" - ] - }, - { - "file": "src/relay/relay-pty-source-restore-retry.test.ts", - "assertions": [ - "invalid-checkpoint cancellation retains the exact delivery until restore response settlement, then retry mints a fresh activation and emits one live source frame" - ] - }, { "file": "src/wsl-guest/protocol-backpressure.test.ts", "assertions": [ @@ -13148,32 +12661,7 @@ ] }, { - "file": "src/main/ssh/ssh-relay-session-data-delivery.test.ts", - "assertions": [ - "the state-machine stale-owner error clears cached state and retries exactly once without resume", - "completion visible before lease-held recovery frames transfers those frames only into private quarantine and projects them once after exact checkpoint-to-recoveryEnd coverage and live handoff", - "invalid recovery cancels only its replacement token without physical PTY or ownership teardown" - ] - }, - { - "file": "src/main/ssh/ssh-relay-session-recovery-races.test.ts", - "assertions": [ - "empty recovery retains recoveryEndSu as the first live-frame continuity anchor", - "validated token cancellation drops queued late frames without physical PTY teardown", - "after recovery quarantine ownership transfer, rejected cancellation publication or proof closes only the exact provider generation and releases its provider, publishers, mux, and activation state once", - "negative, credited-ahead, checkpoint-mismatched, and under-covering recovery cancellation proofs fail closed against the highest privately observed range", - "overlapping recovery never cancels on or mutates the replacement mux, checkpoint, lease, provider state, or ownership" - ] - }, - { - "file": "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts", - "assertions": [ - "10,000 ordered token cancellations for one PTY retain one latest-token retirement record", - "the next activation or PTY exit boundary clears only that PTY's retirement record" - ] - }, - { - "file": "src/main/ssh/ssh-multiplexer-transport-writer.test.ts", + "file": "src/main/ssh/ssh-multiplexer-transport-writer.test.ts", "assertions": [ "write(false) owns one frame and later ordinary traffic waits for drain", "queued control is selected before ordinary PTY backlog at drain while each lane remains FIFO", @@ -13208,14 +12696,6 @@ "assertions": [ "WSL stdin forwards write(false), callback settlement, and drain without claiming a live WSL topology" ] - }, - { - "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", - "assertions": [ - "a stalled renderer ACK produces an exact 256 KiB negotiated source-credit plateau while a second SSH PTY remains responsive", - "fixed-size filesystem frames and Git churn complete without stream corruption while active typing remains within budget", - "the negotiated owner lease reconnects and the existing SSH workspace terminal remains usable" - ] } ], "evidenceRuns": [ @@ -13250,20 +12730,11 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts --reporter=dot", "result": "passed", "durationSeconds": 2.24, "summary": "Sixteen deterministic SSH relay/session/source/decoder files passed 192 tests, including exit-sealed private recovery, retained stale-transfer cancellation authority, exact private-frame proof watermarks, one retirement record across 10,000 same-PTY token rotations, stale-owner fallback, and scheduler rejection isolation; no live topology was exercised." }, - { - "date": "2026-07-29", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", - "result": "passed", - "durationSeconds": 11.81, - "summary": "Two deterministic relay stream and PTY batching files passed 120 tests, including exact encoded chunk capacity and transformed-to-plain metadata isolation." - }, { "date": "2026-07-28", "runner": "local", @@ -13277,7 +12748,7 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", "result": "passed", "durationSeconds": 6.89, "summary": "Thirteen current-head deterministic files passed 150 tests across exit-sealed private recovery, stale-transfer cancellation authority, bounded latest-token retirement, restore retirement, fail-closed recovery cancellation, partial ACK and token rotation, reclaimed-span replacement, mux lane fairness, provisional activation, and exit-proof ordering; no live topology was exercised." @@ -13286,7 +12757,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session.test.ts --reporter=dot", + "command": "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.86, "summary": "Seven current-worktree provider/session files passed 94 tests after a red deterministic seam reproduced completion visibility before lease-held recovery data; the candidate transfers held and later frames only into private recovery quarantine, final-commits after fence/admission, retires exited activation state, and preserves fail-closed malformed, gapped, overlapping, missing-body, cancellation-proof, and replacement behavior." @@ -13295,37 +12766,10 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.63, "summary": "Five deterministic provider, relay, deploy, and session files passed 70 tests after red seams proved that source frames mutated legacy incarnation state and generic restore-required reattach returned an outputless live PTY. The candidate keeps exact source identity side-effect free, fails generic restore-required reattach as expired, offers V1 through reconnect, and proves a failed recovery activation stays private and exact-retryable until successful response settlement; no live topology was exercised." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 60, - "summary": "All four deployed Linux relay/direct-SSH cases passed after an exact-current E2E rebuild at code commit 5611bb45b51a/tree 8e85c3afedec: main bundle SHA-256 cbaf4e997d74bbe0ae1179bc20e52b122c60f0b56ee49ca607e12ae4125a4342 and Linux-x64 relay SHA-256 a7438fc47c4da0223ceaafab621086e14b6cdee427f53a3bf2e710fa99bcf2e6. Direct typing was 3.7/109.1 ms median/worst, ACK-stalled typing was 5.1/109.7 ms at exactly 262,144 held source units, fixed-size filesystem/Git churn was 144.9/153.2 ms with 104 bulk reads, and owner-lease reconnect completed in 15.8 seconds." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 60, - "summary": "Four post-rebase Docker OpenSSH/deployed-relay tests passed: direct typing median/worst 107.7/113.6 ms, stalled-ACK typing 3.6/107.3 ms at an exact 262,144-source-unit plateau, fixed-size filesystem/Git churn 148.1/161.1 ms with 93 bulk reads, and terminal owner-lease reconnect in 15.7 seconds." - }, - { - "date": "2026-07-29", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 56.7, - "summary": "All four always-on deployed Linux relay/direct-SSH cases passed after rebuilding at current-main merge commit adba3410fe4427ceb7525f3fdce2ec58973263a7/tree e43d87340666ca3a733bdb38007c24f95be3f219: main bundle SHA-256 3c304ffc0618520e42bede9a52f72d4b7bb68cbfe543974f89e82a3c477c44b3 and Linux-x64 relay SHA-256 366cb7ccf2e4b388cc6f81a8f6055ab9fde57d2d91381767005c15908832e776. Direct typing was 5.3/108.9 ms median/worst, ACK-stalled typing was 4.8/107.6 ms at exactly 262,144 held source units, fixed-size filesystem/Git churn was 152.0/173.3 ms with 84 bulk reads, and owner-lease reconnect completed in 14.3 seconds; no paired-runtime topology was exercised." } ], "runtimeBudget": { @@ -13599,8 +13043,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/preload/pty-snapshot-capability-ipc.test.ts", @@ -13611,8 +13054,7 @@ "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts", "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts", "tests/e2e/pty-snapshot-capability-main-stall.spec.ts", - "tests/e2e/terminal-cold-activation-deferral.spec.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts" + "tests/e2e/terminal-cold-activation-deferral.spec.ts" ], "assertionRefs": [ { @@ -13729,9 +13171,7 @@ "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", @@ -13795,9 +13235,7 @@ "src/renderer/src/runtime/runtime-client-events.test.ts", "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts", "src/shared/ssh-retained-payload-admission.test.ts", - "tests/e2e/terminal-cold-activation-deferral.spec.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts", - "tests/e2e/ssh-docker-relay-perf.spec.ts" + "tests/e2e/terminal-cold-activation-deferral.spec.ts" ], "assertionRefs": [ { @@ -14169,21 +13607,6 @@ "assertions": [ "abandoning an obsolete reattach drops its data, replay, write-unavailable, and exit handlers without killing the durable PTY" ] - }, - { - "file": "tests/e2e/ssh-cold-activation-restore.spec.ts", - "assertions": [ - "six relay-backed tabs retain the same six PTY ids across renderer reload", - "all six SSH managers mount eagerly and none is parked", - "restored terminal input reaches a proof file on the Linux SSH host" - ] - }, - { - "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", - "assertions": [ - "repo and worktree setup fails closed unless the exact direct SSH host and complete authority are returned", - "a reconnected terminal accepts input and writes a proof file visible inside the Linux SSH target" - ] } ], "evidenceRuns": [ @@ -14241,15 +13664,6 @@ "durationSeconds": 2.54, "summary": "Seven deterministic STA-3107 contracts passed across six-pane multiplex rematerialization and client-owned sidebar-row retention after sleep/wake." }, - { - "date": "2026-07-14", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 128, - "summary": "Six focused files and 475 tests passed; the local cold-activation journey passed; the Docker/Linux SSH journey restored six of six original relay PTYs, mounted six of six managers without parking, and executed remote input through the restored terminal." - }, { "date": "2026-07-17", "runner": "local", @@ -14285,24 +13699,6 @@ "result": "passed", "durationSeconds": 1.48, "summary": "All 90 transport tests passed, including pre-publication admission rejection, handler-complete non-destructive detach for obsolete SSH reattach transports, settled retirement of rejected or destroyed fresh fallbacks, and reported shutdown refusal." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 51.4, - "summary": "Four Electron Docker SSH tests passed on the final implementation, including exact-authority repo/worktree hydration, two concurrent immutable file streams under Git churn, live terminal input before and after disconnect/reconnect, and an independent container-visible remote proof file." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 12.5, - "summary": "One Electron journey passed on the final implementation after exact-authority hydration; six restored SSH terminal managers remounted after renderer reload and remote input reached the Linux target." } ], "runtimeBudget": { @@ -16184,7 +15580,7 @@ "commands": [ "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot", @@ -16215,7 +15611,6 @@ "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts", "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts", "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts", - "src/main/ssh/ssh-remote-orca-cli.test.ts", "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts", "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts" @@ -16353,13 +15748,6 @@ "file": "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts", "assertions": ["federated worker placement explicitly sets activate=false"] }, - { - "file": "src/main/ssh/ssh-remote-orca-cli.test.ts", - "assertions": [ - "implicit SSH fallback checks retain stable pane identity", - "explicit legacy SSH inspection does not inherit the caller pane key" - ] - }, { "file": "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", "assertions": [ @@ -16503,7 +15891,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts", "result": "passed", "durationSeconds": 5.27, "summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity." @@ -17562,8 +16950,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts", "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts", @@ -17575,8 +16962,7 @@ "src/shared/pty-startup-ingress-live-query-reply.test.ts", "src/shared/pty-startup-reply-echo-shapes.test.ts", "tests/e2e/terminal-osc-color-queries.spec.ts", - "tests/e2e/terminal-typing-latency.spec.ts", - "tests/e2e/pty-input-write-queue-ssh.spec.ts" + "tests/e2e/terminal-typing-latency.spec.ts" ], "assertionRefs": [ { @@ -17647,13 +17033,6 @@ "assertions": [ "real keyboard input reaches and echoes from the active local PTY within the interactive latency ceilings" ] - }, - { - "file": "tests/e2e/pty-input-write-queue-ssh.spec.ts", - "assertions": [ - "a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue", - "an upstream-node-pty Linux relay has no echoState export and keeps OSC/DA1 reply bytes out of the next fish child stdin" - ] } ], "evidenceRuns": [ @@ -17683,24 +17062,6 @@ "result": "passed", "durationSeconds": 10.6, "summary": "Real keyboard events traversed xterm and the input queue to a local interactive PTY with every sampled key echoed inside the latency ceiling." - }, - { - "date": "2026-08-09", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 36.8, - "summary": "A real Linux process behind Docker OpenSSH emitted an OSC 10 query and observed xterm's synthetic RGB reply after it traversed the live renderer queue, Electron IPC, SSH provider, and relay PTY." - }, - { - "date": "2026-08-14", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 36.9, - "summary": "Run with SKIP_BUILD=1 against the existing e2e bundle. Both Docker OpenSSH Electron tests passed: the live OSC query returned its RGB reply, and an upstream-node-pty Linux ARM64 relay without echoState kept OSC/DA1 bytes out of the next fish 4.8.1 child's stdin." } ], "runtimeBudget": { @@ -18862,24 +18223,20 @@ "oracle": "Fill the global watcher-child reservation, require native desktop, paired-runtime, and WSL setup to retain typed transient capacity and retry after one serialized release, then cancel pending desktop and paired-client watches and require no retry. Hold one live anchor while aborting 10,000 callers at the capacity, quarantine-assignment, runtime-root, relay-setup, and SSH-registration layers; require exactly one explicit waiter and one base-promise reaction to remain, then zero after settlement. Hold an old relay unsubscribe, queue one same-root setup leader and one anchor, then abort 10,000 joiners; require every cancellation to settle before teardown releases, exactly one anchor waiter, and only one later physical install. Advance the former paired-web callback deadline while capacity remains full, require cancellation ownership to stay live, publish the server token before ready, and require files.unwatch to abort setup before a later slot release can install a native root. Reject the first ready-watch unwatch with a physical-exit promise, resolve that exit later, and require a fresh physical unwatch attempt, registry release, and exactly one stream end. Fill one healthy shared child with multiple roots, map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor, then inject IPC disconnect-before-exit, queued and active cancellation, operations during termination, kill-without-exit, repeated unkillable generations, initial and recovery timeouts, and shard failure. During local and SSH deletion, attempt a late terminal spawn whose worktree identity and cwd name different roots, enumerate a non-prefix remote PTY by authoritative worktree ownership, withhold daemon/relay exit callbacks, make attach positively prove a concurrent-shutdown PID dead, and make the provider unavailable; require every acquired admission to roll back on rejection, dead-PID proof to settle physical exit before reaping, timeout to retain ownership, and Git/filesystem deletion never to start. Require bounded watcher process count, physical-exit settlement, healthy-root recovery, stale-generation rejection, overflow plus later events, and fail-closed destructive cleanup. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-pending-setup-waiters.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-parent-removal.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "pnpm run build:electron-vite", "node config/scripts/runtime-file-watcher-resource-probe.mjs", "node config/scripts/runtime-file-watcher-fault-harness.mjs", "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", - "pnpm run build:relay", - "node config/scripts/relay-watcher-fault-harness.mjs" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs" ], "testFiles": [ "src/main/daemon/daemon-pty-adapter.test.ts", @@ -18918,8 +18275,6 @@ "src/main/runtime/rpc/methods/files.test.ts", "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts", "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts", - "src/main/providers/ssh-filesystem-provider.test.ts", - "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", "src/main/providers/ssh-pty-provider.test.ts", "src/renderer/src/runtime/runtime-file-client.test.ts", "src/renderer/src/web/web-runtime-client.test.ts", @@ -18928,21 +18283,8 @@ "src/shared/promise-settlement-waiters.test.ts", "config/scripts/runtime-file-watcher-fault-harness.mjs", "config/scripts/runtime-file-watcher-resource-probe.mjs", - "src/relay/relay-filesystem-watch-registry.test.ts", - "src/relay/relay-watcher-pending-setup-waiters.test.ts", - "src/relay/relay-watcher-parent-removal.test.ts", - "src/relay/relay-watcher-setup-wait.test.ts", - "src/relay/fs-handler.test.ts", - "src/relay/pty-handler-dispose-lifecycle.test.ts", - "src/relay/pty-handler-shutdown-signals.test.ts", - "src/relay/pty-handler-spawn-admission.test.ts", - "src/relay/pty-handler-attach-replay.test.ts", - "src/relay/pty-handler-revive.test.ts", - "src/relay/git-handler.test.ts", - "src/relay/subprocess.test.ts", "src/main/ssh/ssh-relay-versioned-install.test.ts", - "config/scripts/package-electron-runtime-contract.test.mjs", - "config/scripts/relay-watcher-fault-harness.mjs" + "config/scripts/package-electron-runtime-contract.test.mjs" ], "assertionRefs": [ { @@ -19240,21 +18582,6 @@ "a failed paired-web unwatch retries after already-resolved or late physical exit and emits stream end exactly once" ] }, - { - "file": "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", - "assertions": [ - "10,000 cancelled SSH callers detach while one registration-owned setup anchor remains" - ] - }, - { - "file": "src/main/providers/ssh-filesystem-provider.test.ts", - "assertions": [ - "an SSH caller abort rejects promptly without cancelling another same-root subscriber's shared setup", - "the physical relay request is cancelled when the last pending subscriber leaves", - "provider disposal cancels pending setup and sends one remote unwatch", - "equivalent Windows spellings share one provider generation and terminal failure reaches the surviving owner" - ] - }, { "file": "src/main/providers/ssh-pty-provider.test.ts", "assertions": [ @@ -19276,72 +18603,6 @@ "a later same-root listener opens a fresh server subscription instead of reusing a dead stream" ] }, - { - "file": "src/relay/relay-watcher-parent-removal.test.ts", - "assertions": [ - "POSIX and Windows parent deletion closes and terminally notifies descendant watches before removal while preserving path siblings", - "parent deletion waits for descendant setup publication and already-retiring descendant teardown before removal" - ] - }, - { - "file": "src/relay/relay-watcher-pending-setup-waiters.test.ts", - "assertions": [ - "10,000 aborted relay setup callers detach while one same-root teardown anchor remains and only one replacement installs" - ] - }, - { - "file": "src/relay/relay-watcher-setup-wait.test.ts", - "assertions": [ - "10,000 cancelled relay setup callers leave exactly one explicit anchor and one base-promise reaction" - ] - }, - { - "file": "src/relay/relay-filesystem-watch-registry.test.ts", - "assertions": [ - "each SSH host keeps its own healthy watcher supervisor while a standard repository's three distinct roots share that one child", - "shared-child failure moves those roots into separate quarantine supervisors with overflow and resumed events", - "replacement emits overflow before later ordered events", - "terminal shard failure preserves same-root clients through recovery", - "pending setup aborts only after the last same-root client leaves", - "relay-side removal sends every client a terminal failure, closes every watch, and fences new same-root watcher or PTY setup even when deletion fails", - "a missing child fails closed even if the relay inherits a VITEST environment" - ] - }, - { - "file": "src/relay/fs-handler.test.ts", - "assertions": [ - "shared watches retain the twenty-root budget and release after every client leaves", - "recursive relay deletion closes and terminally notifies same-root watches before removing the directory" - ] - }, - { - "file": "src/relay/pty-handler-dispose-lifecycle.test.ts", - "assertions": [ - "immediate relay shutdown stays pending with the PTY map entry retained until onExit", - "a missing exit rejects at the bounded deadline, preserves the physical owner, and a retry joins without duplicate SIGKILL", - "worktree removal waits explicit and default-cwd PTY creation, rejects later creation, and physically stops only authoritative matching PTYs before deletion" - ] - }, - { - "file": "src/relay/pty-handler-shutdown-signals.test.ts", - "assertions": [ - "Windows relay graceful, fallback, immediate, stale-spawn, exit-dispose, and handler-dispose paths issue at most one ConPTY kill" - ] - }, - { - "file": "src/relay/pty-handler-spawn-admission.test.ts", - "assertions": [ - "relay PTY admission succeeds when post-Node-18 array copy methods are unavailable", - "concurrent spawn and revive admission is atomic, capped at fifty physical PTYs, and same-id revive is deduplicated", - "relay spawn and revive fence distinct worktree-ID and cwd roots, rolling back earlier admissions in reverse when either root is removing" - ] - }, - { - "file": "src/relay/pty-handler-attach-replay.test.ts", - "assertions": [ - "attach dead-PID proof settles a concurrent immediate shutdown before the stale PTY entry is reaped" - ] - }, { "file": "src/renderer/src/web/web-runtime-client.test.ts", "assertions": [ @@ -19349,19 +18610,6 @@ "transient shared-socket loss replays an active file watch under a new transport id while preserving its logical handle and never revives a stopped watch" ] }, - { - "file": "src/relay/git-handler.test.ts", - "assertions": [ - "remote Git worktree removal executes inside the relay filesystem watcher fence" - ] - }, - { - "file": "src/relay/subprocess.test.ts", - "assertions": [ - "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child", - "the Node-18-targeted relay bundle contains no unsupported array copy methods" - ] - }, { "file": "src/main/ssh/ssh-relay-versioned-install.test.ts", "assertions": [ @@ -19388,13 +18636,6 @@ "the harness parent survives watcher-child SIGSEGV", "the real supervisor automatically replaces the child, resubscribes, and delivers a post-crash event" ] - }, - { - "file": "config/scripts/relay-watcher-fault-harness.mjs", - "assertions": [ - "the built relay and existing PTY survive watcher-child fault injection", - "replacement emits overflow and a precise post-crash event" - ] } ], "evidenceRuns": [ @@ -19402,7 +18643,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.98, "summary": "Thirty-nine watcher, cancellation, desktop/serve, paired-web, WSL, SSH/relay, removal, and PTY physical-exit files passed with 2,008 tests. Capacity, quarantine, runtime-root, relay, and SSH setup layers each prove that 10,000 aborted callers detach while one anchor remains and zero waiters remain after settlement." @@ -19411,7 +18652,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.59, "summary": "Thirty-four watcher, cancellation, desktop/serve, paired-web, WSL, removal, PTY physical-exit, provider, renderer, and path files passed with 1,999 tests; paired-web cancellation now owns a server token before ready and aborts pending setup without a late native install." @@ -19429,7 +18670,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.01, "summary": "Twenty-seven watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,886 tests. The suite includes nested removal fencing, renderer/runtime cwd-only admission, legacy provider rows, rejected kill retries, transient WSL capacity, and synchronous-startup reservation cleanup." @@ -19447,7 +18688,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 10.01, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests." @@ -19483,25 +18724,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm run build:relay", - "result": "passed", - "durationSeconds": 0.38, - "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "node config/scripts/relay-watcher-fault-harness.mjs", - "result": "passed", - "durationSeconds": 0.12, - "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", "result": "passed", "durationSeconds": 9.12, "summary": "Twenty-five watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,762 tests. The suite covers operations joining in-flight termination, kill failures retaining ownership, bounded timeouts, atomic relay spawn/revive caps, and PTY-removal admission." @@ -19519,7 +18742,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 10.69, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests, including watcher/PTY removal fencing and recursive deletion failure behavior." @@ -19555,25 +18778,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm run build:relay", - "result": "passed", - "durationSeconds": 0.39, - "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "node config/scripts/relay-watcher-fault-harness.mjs", - "result": "passed", - "durationSeconds": 0.12, - "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." - }, - { - "date": "2026-07-13", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts", "result": "passed", "durationSeconds": 9.03, "summary": "Twenty-three watcher, desktop/serve removal, terminal-spawn gate, daemon/SSH physical PTY exit, provider ownership, RPC, renderer, and cross-platform path files passed with 1,630 tests. The suite includes same-turn watcher replacement, authoritative SSH teardown before Git, bounded kill-without-exit failures that retain ownership, and local/SSH deletion fail-closed behavior." @@ -19591,25 +18796,16 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", "result": "passed", "durationSeconds": 9.79, "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 225 tests, including terminal removal notification and recursive deletion fencing." }, - { - "date": "2026-07-12", - "runner": "local", - "platform": "macos", - "command": "node config/scripts/relay-watcher-fault-harness.mjs", - "result": "passed", - "durationSeconds": 0.61, - "summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child." - }, { "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "result": "passed", "durationSeconds": 8.85, "summary": "18 focused supervisor, disconnect termination, pool, child-entry, delivery, desktop/WSL cleanup and IPC removal, removal-gate, runtime-host/commands and physical PTY teardown, RPC, SSH ownership, renderer/paired-web clients, and cross-platform path files passed with 1,193 tests." @@ -19693,12 +18889,9 @@ "invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.", "oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts" - ], - "testFiles": [ - "src/main/plugins/plugin-host-methods.test.ts", - "src/main/plugins/plugin-host-conformance.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts" ], + "testFiles": ["src/main/plugins/plugin-host-methods.test.ts"], "assertionRefs": [ { "file": "src/main/plugins/plugin-host-methods.test.ts", @@ -19707,14 +18900,6 @@ "matching local- and SSH-shaped opaque ids each perform one capped list and one exact send", "workspace.readContext drops provider paths, path-bearing internal worktree ids, and terminal titles while capping its terminal projection" ] - }, - { - "file": "src/main/plugins/plugin-host-conformance.test.ts", - "assertions": [ - "all 13 v0 methods succeed with the required consented capability through desktop-main and relay adapters", - "missing consent, missing capability, unknown method, malformed params, panel-forbidden access, malformed results, and mutation-audit failure return identical codes", - "malformed qualified keys, client-supplied grants, and client-supplied transport flags are rejected before host policy resolution" - ] } ], "evidenceRuns": [ @@ -19722,7 +18907,7 @@ "date": "2026-07-10", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts", "result": "passed", "durationSeconds": 0.18, "summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts." @@ -19782,19 +18967,15 @@ "invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.", "oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", - "pnpm exec electron-vite build --mode e2e", - "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e" ], "testFiles": [ "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "src/main/ssh/ssh-port-forward.test.ts", "src/main/ssh/system-ssh-forward-process.test.ts", "src/main/ipc/ssh.test.ts", - "src/main/ssh/ssh-relay-session.test.ts", - "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", - "tests/e2e/ssh-port-forward-lifecycle.spec.ts" + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts" ], "assertionRefs": [ { @@ -19820,17 +19001,9 @@ "persists desired forwards and broadcasts when an active forward closes unexpectedly" ] }, - { - "file": "src/main/ssh/ssh-relay-session.test.ts", - "assertions": ["cleans up port forwards on reconnect"] - }, { "file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", "assertions": ["holds only the first matching request while its snapshot is unresolved"] - }, - { - "file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts", - "assertions": ["keeps a user-forwarded listener live across scan refresh @headful"] } ], "evidenceRuns": [ @@ -19838,28 +19011,10 @@ "date": "2026-07-30", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", "result": "passed", "durationSeconds": 6.77, "summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts." - }, - { - "date": "2026-07-30", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 53.7, - "summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning." - }, - { - "date": "2026-07-30", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 66, - "summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed." } ], "runtimeBudget": { @@ -20285,88 +19440,6 @@ ], "demotionRule": "Keep experimental or demote if any case terminates on a signal, the preconditions stop holding on the CI runner, or an assertion can be satisfied by anything other than the command's own output." }, - { - "id": "ssh-managed-hooks.node18-runtime-compatibility", - "title": "SSH managed-hook companions load and install hooks on Node 18", - "maturity": "experimental", - "protection": "partial", - "owner": "agent-hooks", - "layer": "ssh-relay-companion-runtime", - "surfaces": [ - "SSH managed-hook companion startup", - "remote Codex hook installation", - "remote Claude hook installation" - ], - "platforms": ["macos", "linux", "windows"], - "providers": ["ssh"], - "coveredPlatforms": ["macos"], - "coveredProviders": ["ssh"], - "coverageNotes": "A real Node 18 process loads every platform's built companion and runs the bundled Codex and Claude installers against an isolated temporary home. The six artifacts share one platform-neutral source bundle; live SSH transport and physical remote hosts remain uncollected.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/12617"], - "invariant": "Every managed-hook companion shipped for Node 18 must load before agent installers execute, and importing desktop-only state inspection must not make unrelated remote installers depend on node:sqlite.", - "oracle": "Build all relay artifacts, require each managed-hook-runtime.js in a real Node 18 process, assert its installer export, then install only Codex and Claude into a fresh temporary home. Require two successful installer results, executable hook scripts, and both agent configs to reference their managed scripts without touching the user's home.", - "commands": [ - "pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs", - "pnpm exec vitest run --config config/vitest.config.ts config/scripts/pr-workflow-parallelism.test.mjs" - ], - "testFiles": [ - "config/scripts/smoke-managed-hook-runtime-node18.mjs", - "config/scripts/pr-workflow-parallelism.test.mjs" - ], - "assertionRefs": [ - { - "file": "config/scripts/smoke-managed-hook-runtime-node18.mjs", - "assertions": [ - "all six built companions load and export installManagedHooks under Node 18", - "Codex and Claude installations both succeed in an isolated home", - "both generated configs reference executable managed hook scripts" - ] - }, - { - "file": "config/scripts/pr-workflow-parallelism.test.mjs", - "assertions": [ - "PR CI builds the companions before switching to Node 18 and running the smoke oracle" - ] - } - ], - "evidenceRuns": [ - { - "date": "2026-08-08", - "runner": "local", - "platform": "macos", - "result": "passed", - "command": "pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs", - "durationSeconds": 4, - "summary": "All six companions loaded under Node 18.20.8; Codex and Claude produced two successful results, executable hook scripts, and managed configs in a temporary home." - } - ], - "runtimeBudget": { - "p95Seconds": 30, - "scope": "six built-companion loads plus two isolated local-filesystem installer executions" - }, - "flakeHistory": { - "status": "not-started", - "evidence": "New deterministic artifact smoke with no timing oracle; CI history is not yet available." - }, - "redGreenEvidence": { - "status": "complete", - "evidence": "On main de4f272b31, the same Node 18 require exited 1 before exports with ERR_UNKNOWN_BUILTIN_MODULE for node:sqlite. The candidate passes all six loads and both installers; restoring the eager node:sqlite value import reproduces the original exit." - }, - "performanceBudget": { - "required": false, - "evidence": "The product change replaces one eager built-in import with a constructor-time built-in lookup. It adds no polling, IPC, filesystem scan, subprocess, listener, or recurring work." - }, - "promotionCriteria": [ - "Collect 100 consecutive focused CI passes or 14 days of soak history.", - "Collect one live SSH install on Linux and one on Windows OpenSSH.", - "Keep all-platform load, selected installer, isolated-home, and workflow-order assertions green." - ], - "knownGaps": [ - "The artifact journey uses local filesystem calls rather than a live SSH transport.", - "Platform-named bundles are loaded on macOS locally; physical Linux and Windows evidence is not yet recorded." - ], - "demotionRule": "Demote if any shipped companion stops loading on Node 18, selected installers mutate outside the isolated home, or the CI lane no longer runs the built artifact with Node 18." - }, { "id": "terminal-history.daemon-checkpoint-session-isolation", "title": "Daemon history checkpoints isolate stalled terminal sessions", @@ -20493,17 +19566,12 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts --reporter=verbose", "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_SKILL_UPLOAD_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose" + "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose" ], "testFiles": [ "src/main/skills/skill-upload-session-service.test.ts", "src/main/skills/skill-upload-process-restart.integration.test.ts", - "src/main/skills/skill-upload-process-restart-child.test.ts", - "src/relay/skill-upload-multi-relay.integration.test.ts" + "src/main/skills/skill-upload-process-restart-child.test.ts" ], "assertionRefs": [ { @@ -20526,14 +19594,6 @@ "begun, partial, uploaded, and committed crash residue is reclaimed", "the replacement transfer completes and leaves no owner directory" ] - }, - { - "file": "src/relay/skill-upload-multi-relay.integration.test.ts", - "assertions": [ - "two bundled relay PIDs retain separate archives under one account home", - "current and originating relay bundles retain separate archives in both launch orders", - "relay cancellation and live-upload graceful shutdown clean only the exact owner" - ] } ], "evidenceRuns": [ @@ -20542,36 +19602,9 @@ "runner": "local", "platform": "macos", "result": "passed", - "command": "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", + "command": "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", "durationSeconds": 5.95, "summary": "Fifteen service tests, four crash-boundary cases, and one two-current-relay topology passed; the child fixture and two explicitly separate mixed-version cases skipped." - }, - { - "date": "2026-08-20", - "runner": "local", - "platform": "macos", - "result": "passed", - "command": "ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "durationSeconds": 0.9, - "summary": "The current/current case and both originating/current mixed-version launch orders passed with exact per-root archive bytes." - }, - { - "date": "2026-08-20", - "runner": "local", - "platform": "macos", - "result": "failed", - "command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY= pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "durationSeconds": 0.4, - "summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing." - }, - { - "date": "2026-08-20", - "runner": "local", - "platform": "macos", - "result": "failed", - "command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose", - "durationSeconds": 0.4, - "summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing." } ], "runtimeBudget": { @@ -21212,64 +20245,14 @@ "invariant": "Transport loss and frozen-host silence must preserve the remote session; host relay loss may rebind a pane without accumulating reattachable leases. Reconnects must preserve usable terminal content, bounded PTYs/fds/processes, complete large listings, and independently recoverable watcher processes. Electron test shutdown must release inherited pipes after confirmed root exit without closing live-process pipes.", "oracle": "Poll a changed connected SSH authority after injected faults, then require terminal output and appropriate PTY identity. Read remote process/fd state, listFiles replies, and rendered explorer rows. Resolve Playwright cleanup only after the root process exits and its inherited pipes close; live-process pipes remain untouched.", "commands": [ - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.spec.ts tests/e2e/ssh-docker-half-open-link.spec.ts tests/e2e/ssh-docker-quick-open-large-listing.spec.ts tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts tests/e2e/ssh-docker-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 --repeat-each=10", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-codex-display-artifacts-repro.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts" ], "testFiles": [ - "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts", - "tests/e2e/ssh-docker-half-open-link.spec.ts", - "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts", - "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts", - "tests/e2e/ssh-docker-resource-accumulation.spec.ts", - "tests/e2e/ssh-docker-watcher-isolation.spec.ts", "tests/e2e/helpers/electron-process-shutdown.unit.test.ts", - "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts", - "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts", - "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts", - "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" + "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts" ], "assertionRefs": [ - { - "file": "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts", - "assertions": [ - "preserves transport-drop PTY and scrollback, replaces relay-loss binding, and keeps one reattachable lease per pane" - ] - }, - { - "file": "tests/e2e/ssh-docker-half-open-link.spec.ts", - "assertions": [ - "leaves connected after host freeze and renders process-produced output after recovery" - ] - }, - { - "file": "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts", - "assertions": [ - "returns both a bounded client page and a complete legacy-client remote listing" - ] - }, - { - "file": "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts", - "assertions": [ - "restores shell scrollback and full-screen output and opens a usable fresh tab" - ] - }, - { - "file": "tests/e2e/ssh-docker-resource-accumulation.spec.ts", - "assertions": [ - "keeps remote pts devices, relay fds, process counts and inherited master fds bounded" - ] - }, - { - "file": "tests/e2e/ssh-docker-watcher-isolation.spec.ts", - "assertions": [ - "keeps rendered explorer changes and terminal output live after watcher crash and repairs a deleted watcher artifact" - ] - }, { "file": "tests/e2e/helpers/electron-process-shutdown.unit.test.ts", "assertions": [ @@ -21277,24 +20260,6 @@ "retains live-process pipes on shutdown timeout" ] }, - { - "file": "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts", - "assertions": [ - "five flooding SSH panes remain below unchanged 2500ms soft and 5000ms hard freeze budgets during bulk reopen and two double-animation-frame view changes" - ] - }, - { - "file": "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts", - "assertions": [ - "five distinct SSH PTYs acknowledge actual keyboard input after two rendered hide/reopen cycles while all five producers flood" - ] - }, - { - "file": "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts", - "assertions": [ - "normal restore and forced SSH reconnect leave no stale or duplicate status rows; forced reconnect preserves the original PTY and requires nonempty replay from that PTY through an event or reattach reply" - ] - }, { "file": "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts", "assertions": [ @@ -21311,24 +20276,6 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts", "durationSeconds": 0.168, "summary": "All three shutdown regression tests passed; disabling pipe release fails the first two by timeout. Two half-open Electron repetitions separately passed in 1.7m without worker teardown timeout." - }, - { - "date": "2026-09-05", - "runner": "local", - "platform": "macos", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.spec.ts tests/e2e/ssh-docker-half-open-link.spec.ts tests/e2e/ssh-docker-quick-open-large-listing.spec.ts tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts tests/e2e/ssh-docker-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "durationSeconds": 312, - "summary": "Six specs: ten passed, two existing fixme skipped, clean worker shutdown. Baseline same enabled suite: ten passed but worker teardown timed out (7.3m)." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 --repeat-each=10", - "durationSeconds": 396, - "summary": "Original uninstrumented five-pane workload passed all ten repetitions with zero retries/skips in 6.6m; bulk-open lag 79.3–147.8ms and interaction 127.1–155.9ms, unchanged 2500ms/5000ms budgets. Run 34037669843, head f25eab3fd7d723509ced026633f80b193a139b76, excludes unmerged replay-input application fix #19075." } ], "runtimeBudget": { @@ -21934,7 +20881,6 @@ "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/orchestration src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts src/main/runtime/runtime-legacy-worker-missing-workspace-recovery.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/orchestration/worker-dispatch-settlement.test.ts src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts src/main/runtime/runtime-legacy-worker-missing-workspace-recovery.test.ts src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test tests/e2e/profile-state-terminal-restart-persistence.spec.ts tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -g 'repairs a settled worker'", "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts" ], "testFiles": [ @@ -21944,8 +20890,7 @@ "src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts", "src/main/runtime/orca-runtime.test.ts", "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts", - "src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts" + "src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts" ], "assertionRefs": [ { @@ -21993,25 +20938,9 @@ "an older SQL writer can reintroduce stale bookkeeping after repair and the next open still settles it", "second open and repair perform zero worker mutations with 100,000 historical assignments" ] - }, - { - "file": "tests/e2e/ssh-cold-activation-restore.spec.ts", - "assertions": [ - "historical assignment repair preserves a real SSH shell and its live background child through app restart and reconnect", - "rendered terminal output and host-side process proof retain the same identity, environment and working directory" - ] } ], "evidenceRuns": [ - { - "date": "2026-10-05", - "runner": "local", - "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -g 'repairs a settled worker'", - "result": "passed", - "durationSeconds": 42.4, - "summary": "Rebuilt hidden Electron with the active-worker partial index and read-only repair preflight. The real Debian ARM64 SSH journey preserves the same live shell and background child, terminal ownership, input and rendered output through app restart and reconnect." - }, { "date": "2026-10-05", "runner": "local", @@ -22065,15 +20994,6 @@ "result": "passed", "durationSeconds": 2.29, "summary": "Three safety and scale tests pass. One local run with 100,000 assignments and 1,000 stale workers: repair 38.83ms, no-op repair 25.07ms, complete subsequent database open 38.52ms. These are observations, not a CI latency bound." - }, - { - "date": "2026-10-05", - "runner": "local", - "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -g 'repairs a settled worker'", - "result": "passed", - "durationSeconds": 32.1, - "summary": "A rebuilt hidden macOS Electron client against Debian ARM64 Docker SSH repairs historical settled worker bookkeeping while retaining terminal ownership, the same live shell and background child, environment, working directory, input and rendered output through app restart and SSH reconnect. Removing startup repair fails the worker-state assertion; restoring it passes." } ], "runtimeBudget": { diff --git a/config/scripts/benchmark-preflight-compilers.mjs b/config/scripts/benchmark-preflight-compilers.mjs new file mode 100644 index 000000000000..6715004746a9 --- /dev/null +++ b/config/scripts/benchmark-preflight-compilers.mjs @@ -0,0 +1,257 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { availableParallelism, totalmem } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { performance } from 'node:perf_hooks' +import { measurePreflightCommand } from './preflight-benchmark-command.mjs' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { + TYPECHECK_PROJECTS, + admissibleHeapGib, + planTypecheckBatches, + resolveProjectCompiler +} from './run-typecheck-projects-in-parallel.mjs' + +const parityOnly = process.argv.slice(2).includes('--parity-only') +if (process.argv.slice(2).some((argument) => argument !== '--parity-only')) { + throw new Error('Use --parity-only or no arguments') +} +const root = fileURLToPath(new URL('../..', import.meta.url)) +const output = path.join(root, '.build/preflight-benchmark') +const reportName = parityOnly ? 'compiler-parity' : 'compilers' +mkdirSync(output, { recursive: true }) +const bun = process.env.ORCA_BENCHMARK_BUN ?? 'bun' +const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', GOMAXPROCS: '4' } +const report = { + sourceSha: process.env.GITHUB_SHA ?? null, + host: { + platform: process.platform, + arch: process.arch, + node: process.version, + memoryBytes: totalmem(), + cores: availableParallelism() + }, + method: + 'Four counterbalanced pairs per comparison. Cold removes build state, not filesystem caches. Warm immediately follows its cold run. Same commit and project registry for every arm.', + measurements: [], + comparisons: {}, + parity: [] +} +const save = () => + writeFileSync(path.join(output, `${reportName}.json`), `${JSON.stringify(report, null, 2)}\n`) +const command = (program, args, label) => + measurePreflightCommand({ program, args, label, root, output, env }) +const version = await command(bun, ['--version'], 'bun-version') +if (version.code !== 0 || version.stdout.trim() !== '1.4.3') { + throw new Error('Pin Bun 1.4.3 for this comparison') +} +report.host.bun = version.stdout.trim() +report.host.typescript = JSON.parse( + readFileSync(path.join(root, 'node_modules/typescript/package.json')) +).version +const nativeBatches = planTypecheckBatches(TYPECHECK_PROJECTS, { + budgetGib: Math.min(12, admissibleHeapGib(totalmem())), + parallelism: Math.min(4, availableParallelism()) +}) +function invocation(config, arm, extra = []) { + return arm === 'native' + ? [ + process.execPath, + [ + resolveProjectCompiler(config, root), + '--noEmit', + '-p', + config, + '--pretty', + 'false', + ...extra + ] + ] + : [ + bun, + [ + 'check', + '-p', + config, + '--threads', + arm === 'bun-pair' ? '2' : '4', + '--no-pretty', + '--all', + ...extra + ] + ] +} +async function suite(arm, state, pair, comparison) { + const batches = + arm === 'native' + ? nativeBatches + : arm === 'bun-pair' + ? [TYPECHECK_PROJECTS.slice(0, 2), TYPECHECK_PROJECTS.slice(2)] + : TYPECHECK_PROJECTS.map((project) => [project]) + const start = performance.now() + const projects = [] + for (const batch of batches) { + projects.push( + ...(await Promise.all( + batch.map(async ({ config }) => { + const [program, args] = invocation(config, arm) + return { + config, + ...(await command( + program, + args, + `${comparison}-${pair}-${arm}-${state}-${path.basename(config)}` + )) + } + }) + )) + ) + } + const measurement = { + comparison, + pair, + arm, + state, + durationMs: performance.now() - start, + projects + } + report.measurements.push(measurement) + save() + console.log( + JSON.stringify({ + comparison, + pair, + arm, + state, + durationMs: measurement.durationMs, + codes: projects.map((project) => project.code), + peakRssKiB: projects.map((project) => project.peakRssKiB) + }) + ) + if (projects.some((project) => project.code !== 0 || project.timedOut || project.signal)) { + throw new Error(`Compiler failed: ${JSON.stringify(projects)}`) + } +} +function clearBuildState() { + for (const { config } of TYPECHECK_PROJECTS) { + rmSync(path.join(root, config.replace(/\.json$/, '.tsbuildinfo')), { force: true }) + } +} +if (!parityOnly) { + for (const [comparison, first, second] of [ + ['native-vs-bun', 'native', 'bun'], + ['bun-serial-vs-pair', 'bun', 'bun-pair'] + ]) { + for (const [pair, arms] of buildCounterbalancedSchedule(4, first, second).entries()) { + for (const arm of arms) { + clearBuildState() + await suite(arm, arm === 'native' ? 'cold' : 'full', pair, comparison) + if (arm === 'native') { + await suite(arm, 'warm', pair, comparison) + } + } + } + report.comparisons[comparison] = {} + for (const arm of [first, second]) { + for (const state of arm === 'native' ? ['cold', 'warm'] : ['full']) { + const samples = report.measurements.filter( + (sample) => + sample.comparison === comparison && sample.arm === arm && sample.state === state + ) + report.comparisons[comparison][`${arm}-${state}`] = summarizeBenchmarkSamples( + samples.map((sample) => sample.durationMs) + ) + } + } + save() + } +} +const probeRoots = new Map([ + ['config/tsconfig.node.json', 'src/main'], + ['config/tsconfig.tc.web.json', 'src/renderer/src'], + ['config/tsconfig.tc.cli.json', 'src/cli'], + ['src/packages/process-host/tsconfig.test.json', 'src/packages/process-host/src'] +]) +for (const { config } of TYPECHECK_PROJECTS) { + const probeRoot = probeRoots.get(config) + if (!probeRoot) { + throw new Error(`Register an included probe directory for ${config}`) + } + const directory = mkdtempSync(path.join(root, probeRoot, 'preflight-benchmark-probe-')) + const probe = path.join(directory, 'preflight-typecheck-benchmark-probe.ts') + try { + writeFileSync( + probe, + [ + 'export const preflightBenchmarkValue: number = "typecheck must reject this"', + 'export function preflightBenchmarkReturn(value: number): string { return value }', + 'export const preflightBenchmarkNull: string = null', + 'export function preflightBenchmarkImplicit(value) { return value }' + ].join('\n'), + { flag: 'wx' } + ) + for (const arm of ['native', 'bun']) { + const [program, args] = invocation(config, arm) + const result = await command(program, args, `probe-${path.basename(config)}-${arm}`) + report.parity.push({ config, arm, ...result }) + save() + if ( + result.code !== 1 || + (`${result.stdout}${result.stderr}`.match(/: error TS2322:/g) ?? []).length !== 3 + ) { + throw new Error(`Missing expected diagnostic: ${arm} ${config}`) + } + } + const errors = report.parity + .filter((sample) => sample.config === config && sample.code === 1) + .map((sample) => + `${sample.stdout}${sample.stderr}` + .split(/\r?\n/) + .filter((line) => line.includes(': error TS')) + ) + if (JSON.stringify(errors[0]) !== JSON.stringify(errors[1])) { + throw new Error(`Compiler diagnostics differ: ${config}`) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + for (const arm of ['native', 'bun']) { + const [program, args] = invocation(config, arm, ['--listFilesOnly']) + const result = await command(program, args, `files-${path.basename(config)}-${arm}`) + if (result.code !== 0) { + throw new Error(`Could not enumerate ${arm} ${config}: ${result.stderr}`) + } + report.parity.push({ + config, + arm, + files: result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => path.isAbsolute(line) || line.startsWith('bundled:///libs/')) + // Bun embeds the standard library; compare its names with native TypeScript's disk paths. + .map((line) => + line.startsWith('bundled:///libs/') || + /\/(?:@typescript\/typescript-[^/]+|typescript)\/lib\/lib\.[^/]+\.d\.ts$/.test( + line.replaceAll('\\', '/') + ) + ? `typescript-lib:${path.basename(line)}` + : path.normalize(line) + ) + .sort() + }) + } + const [nativeFiles, bunFiles] = report.parity + .filter((sample) => sample.config === config && sample.files) + .map((sample) => sample.files) + save() + if (JSON.stringify(nativeFiles) !== JSON.stringify(bunFiles)) { + throw new Error(`Compiler file selection differs: ${config}`) + } + save() +} +const summary = `Compiler comparison on ${process.platform}/${process.arch}, ${report.host.cores} cores\n\n\`\`\`json\n${JSON.stringify(report.comparisons, null, 2)}\n\`\`\`\n\nAll project file lists match after normalizing embedded standard-library names; both compilers report identical diagnostics for assignment, return-type, strict-null and implicit-any probes under each project's existing settings.\n` +writeFileSync(path.join(output, `${reportName}.md`), summary) +if (process.env.GITHUB_STEP_SUMMARY) { + writeFileSync(process.env.GITHUB_STEP_SUMMARY, summary, { flag: 'a' }) +} diff --git a/config/scripts/benchmark-preflight-static-analysis.mjs b/config/scripts/benchmark-preflight-static-analysis.mjs new file mode 100644 index 000000000000..eb8f255606c9 --- /dev/null +++ b/config/scripts/benchmark-preflight-static-analysis.mjs @@ -0,0 +1,250 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { measurePreflightCommand } from './preflight-benchmark-command.mjs' +import ts from 'typescript-api' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const output = path.join(root, '.build/preflight-benchmark') +const baseline = path.join(output, 'baseline') +mkdirSync(output, { recursive: true }) +const report = { + sourceSha: process.env.GITHUB_SHA ?? null, + host: { platform: process.platform, arch: process.arch, node: process.version }, + measurements: [], + comparisons: {} +} +const save = () => + writeFileSync(path.join(output, 'static-analysis.json'), `${JSON.stringify(report, null, 2)}\n`) +const command = (program, args, label) => + measurePreflightCommand({ + program, + args, + label, + root, + output, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 180_000 + }) +async function requireSuccess(program, args, label) { + const result = await command(program, args, label) + if (result.code !== 0 || result.signal || result.timedOut) { + throw new Error(`${label}: ${JSON.stringify(result)}`) + } + return result +} +const bun = process.env.ORCA_BENCHMARK_BUN ?? 'bun' +report.host.bun = (await requireSuccess(bun, ['--version'], 'bun-version')).stdout.trim() +if (report.host.bun !== '1.4.3') { + throw new Error('Pin Bun 1.4.3') +} +await requireSuccess(process.execPath, ['config/scripts/sync-anti-slop-plugin.mjs'], 'sync-plugin') +rmSync(baseline, { recursive: true, force: true }) +cpSync(path.join(root, '.anti-slop-plugin'), path.join(baseline, 'src'), { recursive: true }) +// Reverse the committed patch so both arms use the same pinned upstream package. +await requireSuccess( + 'git', + [ + 'apply', + '--reverse', + '--directory=.build/preflight-benchmark/baseline', + 'config/patches/oxlint-plugin-anti-slop@0.1.2.patch' + ], + 'reverse-patch' +) +const configText = readFileSync(path.join(root, 'config/oxlint-anti-slop.json'), 'utf8') +const { config, error } = ts.parseConfigFileTextToJson('config/oxlint-anti-slop.json', configText) +if (error) { + throw new Error('Invalid anti-slop configuration') +} +for (const [arm, plugin] of [ + ['baseline', path.join(baseline, 'src/index.ts')], + ['patched', path.join(root, '.anti-slop-plugin/index.ts')] +]) { + writeFileSync( + path.join(output, `${arm}.json`), + JSON.stringify({ ...config, jsPlugins: [{ name: 'anti-slop', specifier: plugin }] }) + ) +} +const oxlint = resolveOxlintInvocation(root) +const tsx = resolveOxcCliInvocation('tsx', 'tsx', root) +for (const [arm, directory] of [ + ['baseline', path.join(baseline, 'src')], + ['patched', path.join(root, '.anti-slop-plugin')] +]) { + for (const rule of ['no-object-parameters', 'no-unknown-type-aliases']) { + const fixture = path.join(directory, 'rules', `${rule}.test.ts`) + await requireSuccess(tsx.command, [...tsx.prefixArgs, fixture], `fixtures-${arm}-${rule}-node`) + } +} +// RuleTester uses V8 raw transfer and rejects Bun; exercise Bun through the real CLI. +const fixturePath = path.join(output, 'anti-slop-invalid-fixture.ts') +writeFileSync( + fixturePath, + 'type InvalidAlias = unknown; type ObjectAlias = object; export function consume(value: ObjectAlias): void {}\n' +) +let fixtureDiagnostics +for (const [runtime, flags] of [ + [process.execPath, []], + [bun, []], + [bun, ['--smol']] +]) { + const result = await command( + runtime, + [ + ...flags, + ...oxlint.prefixArgs, + '--config', + path.join(output, 'patched.json'), + fixturePath, + '--format=json' + ], + `cli-fixture-${path.basename(runtime)}-${flags.length}` + ) + const findings = JSON.parse(result.stdout).diagnostics + if ( + result.code !== 1 || + !findings.some((finding) => finding.code?.includes('no-object-parameters')) || + !findings.some((finding) => finding.code?.includes('no-unknown-type-aliases')) + ) { + throw new Error(`Invalid fixture did not produce expected findings: ${JSON.stringify(result)}`) + } + const normalized = JSON.stringify(findings) + if (fixtureDiagnostics !== undefined && fixtureDiagnostics !== normalized) { + throw new Error('Runtime fixture diagnostics differ') + } + fixtureDiagnostics = normalized +} +const lintArgs = (arm) => [ + ...oxlint.prefixArgs, + '--config', + path.join(output, `${arm}.json`), + 'src', + 'config', + 'tests', + 'mobile', + '--deny-warnings', + '--format=json' +] +for (const [comparison, first, second] of [ + ['anti-slop-patch', 'baseline', 'patched'], + ['anti-slop-runtime', 'node', 'bun'], + ['anti-slop-smol', 'node', 'bun-smol'], + ['import-guard-runtime', 'node', 'bun'] +]) { + let diagnostics + for (const [pair, arms] of buildCounterbalancedSchedule(4, first, second).entries()) { + for (const arm of arms) { + const program = arm.startsWith('bun') ? bun : process.execPath + const args = + comparison === 'import-guard-runtime' + ? ['config/scripts/check-process-host-imports.mjs'] + : lintArgs(comparison === 'anti-slop-patch' ? arm : 'patched') + const result = await requireSuccess( + program, + arm === 'bun-smol' ? ['--smol', ...args] : args, + `${comparison}-${pair}-${arm}` + ) + if (comparison !== 'import-guard-runtime') { + const findings = JSON.stringify(JSON.parse(result.stdout).diagnostics) + if (diagnostics !== undefined && diagnostics !== findings) { + throw new Error(`Diagnostics changed: ${comparison}`) + } + diagnostics = findings + } + report.measurements.push({ comparison, pair, arm, ...result }) + save() + console.log( + JSON.stringify({ + comparison, + pair, + arm, + durationMs: result.durationMs, + peakRssKiB: result.peakRssKiB + }) + ) + } + } + report.comparisons[comparison] = Object.fromEntries( + [first, second].map((arm) => [ + arm, + summarizeBenchmarkSamples( + report.measurements + .filter((sample) => sample.comparison === comparison && sample.arm === arm) + .map((sample) => sample.durationMs) + ) + ]) + ) + save() +} +const guardProbeDirectory = mkdtempSync(path.join(root, 'src/main/preflight-import-probe-')) +const guardProbe = path.join(guardProbeDirectory, 'forbidden-process-import.ts') +try { + writeFileSync( + guardProbe, + "import { spawn } from 'node:child_process'\nexport const forbiddenSpawn = spawn\n" + ) + for (const runtime of [process.execPath, bun]) { + const result = await command( + runtime, + ['config/scripts/check-process-host-imports.mjs'], + `guard-invalid-${path.basename(runtime)}` + ) + if ( + result.code !== 1 || + !`${result.stdout}${result.stderr}`.includes('forbidden-process-import.ts') + ) { + throw new Error(`Import guard did not reject its fixture: ${JSON.stringify(result)}`) + } + } +} finally { + rmSync(guardProbeDirectory, { recursive: true, force: true }) +} +for (const [pair, arms] of buildCounterbalancedSchedule(4, 'baseline', 'optimized').entries()) { + for (const arm of arms) { + const runtime = arm === 'optimized' ? bun : process.execPath + const start = performance.now() + const processes = await Promise.all([ + requireSuccess( + process.execPath, + [...oxlint.prefixArgs, '--format=json'], + `early-${pair}-${arm}-root-lint` + ), + requireSuccess( + runtime, + arm === 'optimized' ? ['--smol', ...lintArgs('patched')] : lintArgs('baseline'), + `early-${pair}-${arm}-anti-slop` + ), + requireSuccess( + runtime, + ['config/scripts/check-process-host-imports.mjs'], + `early-${pair}-${arm}-import-guard` + ) + ]) + const durationMs = performance.now() - start + report.measurements.push({ comparison: 'early-phase', pair, arm, durationMs, processes }) + save() + console.log(JSON.stringify({ comparison: 'early-phase', pair, arm, durationMs })) + } +} +report.comparisons['early-phase'] = Object.fromEntries( + ['baseline', 'optimized'].map((arm) => [ + arm, + summarizeBenchmarkSamples( + report.measurements + .filter((sample) => sample.comparison === 'early-phase' && sample.arm === arm) + .map((sample) => sample.durationMs) + ) + ]) +) +save() +const summary = `Static analysis comparisons on ${process.platform}/${process.arch}\n\n\`\`\`json\n${JSON.stringify(report.comparisons, null, 2)}\n\`\`\`\n\nBoth upstream rule suites pass under Node; real CLI fixtures agree under Node and Bun. Full-scan diagnostics agree between the compared arms.\n` +writeFileSync(path.join(output, 'static-analysis.md'), summary) +if (process.env.GITHUB_STEP_SUMMARY) { + writeFileSync(process.env.GITHUB_STEP_SUMMARY, summary, { flag: 'a' }) +} diff --git a/config/scripts/benchmark-sentinel-retention.mjs b/config/scripts/benchmark-sentinel-retention.mjs deleted file mode 100644 index 93564eeac01c..000000000000 --- a/config/scripts/benchmark-sentinel-retention.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { strict as assert } from 'node:assert' -import { EventEmitter } from 'node:events' -import { mkdtemp, rm } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { build } from 'esbuild' - -if (!global.gc) { - throw new Error('Run with node --expose-gc') -} -const root = resolve(import.meta.dirname, '../..') -const directory = await mkdtemp(join(tmpdir(), 'orca-sentinel-retention-')) -const output = join(directory, 'sentinel.cjs') -try { - await build({ - stdin: { - contents: `export {waitForSentinel} from './src/main/ssh/ssh-relay-deploy-helpers'; -export {RELAY_SENTINEL} from './src/main/ssh/relay-protocol';`, - resolveDir: root, - loader: 'ts' - }, - bundle: true, - platform: 'node', - format: 'cjs', - packages: 'external', - banner: { - js: `var require = require('node:module').createRequire(${JSON.stringify(join(root, 'package.json'))});` - }, - outfile: output - }) - const { waitForSentinel, RELAY_SENTINEL } = createRequire(import.meta.url)(output) - const held = [] - const banners = [] - for (let i = 0; i < 100; i++) { - const channel = Object.assign(new EventEmitter(), { - stderr: new EventEmitter(), - stdin: { write: () => true }, - close: () => {} - }) - const pending = waitForSentinel(channel) - banners.push(feedBanner(channel)) - channel.emit('data', Buffer.from(RELAY_SENTINEL)) - const transport = await pending - const received = [] - transport.onData((bytes) => received.push(bytes.toString())) - channel.emit('data', Buffer.from('frame')) - assert.deepEqual(received, ['frame']) - held.push({ channel, transport }) - } - await new Promise((resolve) => setImmediate(resolve)) - for (let i = 0; i < 5; i++) { - global.gc() - } - const retained = banners.filter((reference) => reference.deref() !== undefined).length - console.log( - JSON.stringify({ - connections: held.length, - bannerBytes: 65536, - retainedBannerBuffers: retained, - retainedBannerBytes: retained * 65536 - }) - ) -} finally { - await rm(directory, { recursive: true, force: true }) -} - -function feedBanner(channel) { - const banner = Buffer.alloc(65536, 120) - channel.emit('data', banner) - return new WeakRef(banner.buffer) -} diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 5099a95994d7..d548e5c65d16 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -31,6 +31,7 @@ import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-s import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' +import { stageOrcadWindowsCliLauncher } from './orcad-windows-cli-launcher.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, ORCAD_CLI_ENTRY_FILENAME, @@ -157,6 +158,7 @@ writeFileSync( stageNodePty(BUILD_TARGET) await stageParcelWatcher(BUILD_TARGET) stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET) +stageOrcadWindowsCliLauncher(ROOT, OUT_DIR, BUILD_TARGET) const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET) mkdirSync(dirname(emojiDatasetOutput), { recursive: true }) copyFileSync( diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index fcff37a82547..320a88c30bd4 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -1,349 +1,72 @@ #!/usr/bin/env node /** - * Bundle the relay daemon and its crash-isolated watcher child per platform. - * - * The relay runs on remote hosts via `node relay.js`, so both outputs use - * self-contained CommonJS bundles with no external dependencies beyond - * Node.js built-ins. Native addons (node-pty, @parcel/watcher) are - * marked external and expected to be installed on the remote or - * gracefully degraded. + * Bundle the programs Orca runs inside WSL distros: the WSL agent-hook relay, the WSL browser + * network relay, the Claude profile helper, and the OpenCode SQLite reader. Each is a + * self-contained CommonJS bundle with only Node.js built-ins; the Windows app ships them from + * out/relay/wsl via the relay extraResources mapping. */ import { build } from 'esbuild' import { JSONC_PARSER_ESM_ALIAS } from '../build-plugins/jsonc-parser-esm.ts' import { createHash } from 'node:crypto' -import { - copyFileSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { - RELAY_BUILD_PLATFORMS, - RELAY_VERSION_FILENAME, RELAY_OPENCODE_SQLITE_READER_FILENAME, - WSL_CLAUDE_PROFILE_HELPER_FILENAME, - relayOptionalArtifactFilenames, - isWindowsRelayPlatform, - relayArtifactFilenames + WSL_CLAUDE_PROFILE_HELPER_FILENAME } from '../../src/shared/relay-artifacts.ts' -import { - parseRequiredRelayAddonArches, - stageRelayWindowsProcessTreeAddon -} from './relay-windows-process-tree-staging.mjs' const __dirname = import.meta.dirname // Why: the script lives under config/scripts, so go two levels up to reach the repo root. const ROOT = join(__dirname, '..', '..') -const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') -const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') -const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts') -const OPENCODE_SQLITE_READER_ENTRY = join( - ROOT, - 'src', - 'main', - 'ai-vault', - 'session-scanner-opencode-sqlite-process-entry.ts' -) -const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join( - ROOT, - 'src', - 'main', - 'native-chat', - 'wsl-transcript-fs-process-entry.ts' -) -const MANAGED_HOOK_RUNTIME_ENTRY = join( - ROOT, - 'src', - 'main', - 'agent-hooks', - 'managed-hook-runtime.ts' -) -const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' -const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( - ROOT, - 'config', - 'relay-assets', - NODE_PTY_CONSOLE_LIST_PATCH_FILENAME -) -const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs' -const NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE = join( - ROOT, - 'config', - 'relay-assets', - NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME -) -const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' -const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join( - ROOT, - 'config', - 'relay-assets', - NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME -) -// Written by build-windows-process-tree-relay-addon.mjs on Windows, or downloaded -// from CI's relay-windows-process-tree artifact on other OSes. -const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree') - -// Per-arch rather than a flag because arm64 needs the MSVC ARM64 cross toolset, -// an optional VS component: where it is absent that relay should fall back to -// the scan, not fail the release the x64 relay is riding on. -const REQUIRED_ADDON_ARCHES = parseRequiredRelayAddonArches( - process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS -) - -// Why: lets the packaging contract test build into a temp tree instead of -// clobbering a developer's out/relay or racing tests that read it. +// Why: lets a contract test build into a temp tree instead of clobbering a developer's out/relay. const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay') - const RELAY_VERSION = '0.1.0' -async function buildRelayBundles(outDir) { - await build({ - entryPoints: [RELAY_ENTRY], - alias: JSONC_PARSER_ESM_ALIAS, - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'relay.js'), - // Native addons cannot be bundled — they must exist on the remote host. - // The relay gracefully degrades when they are absent. - external: ['node-pty', '@parcel/watcher', 'electron'], - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - - await build({ - entryPoints: [WATCHER_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'relay-watcher.js'), - external: ['@parcel/watcher'], - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - - await build({ - entryPoints: [AI_VAULT_SERVICE_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'relay-ai-vault-service.js'), - external: ['electron'], - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - - await build({ - entryPoints: [OPENCODE_SQLITE_READER_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, RELAY_OPENCODE_SQLITE_READER_FILENAME), - external: ['electron'], - sourcemap: false, - minify: true, - define: { 'process.env.NODE_ENV': '"production"' } - }) +const outDir = join(OUT_ROOT, 'wsl') +// Why: the SSH relay's per-platform directories are gone; a stale one must not ship. +rmSync(OUT_ROOT, { recursive: true, force: true }) +mkdirSync(outDir, { recursive: true }) - // Why beside the service: the spawn resolves this child next to its own - // bundle, and a relay host has no desktop out/main to fall back to. - await build({ - entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY], +const bundle = (entry, outfile, extra = {}) => + build({ + entryPoints: [join(ROOT, entry)], bundle: true, platform: 'node', target: 'node18', format: 'cjs', - outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'), - external: ['electron'], + outfile: join(outDir, outfile), sourcemap: false, minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } + define: { 'process.env.NODE_ENV': '"production"' }, + ...extra }) - await build({ - entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'managed-hook-runtime.js'), - // Why: jsonc-parser's default UMD build keeps relative dynamic requires - // that break after bundling; its ESM entry is equivalent and self-contained. - alias: JSONC_PARSER_ESM_ALIAS, - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) -} - -let bundledSourceDir -let bundledFilenames = [] - -for (const platform of RELAY_BUILD_PLATFORMS) { - const outDir = join(OUT_ROOT, platform) - // Why: a stale companion left by an earlier build would otherwise satisfy the - // manifest check and be hashed into .version, shipping mixed-generation bytes. - rmSync(outDir, { recursive: true, force: true }) - mkdirSync(outDir, { recursive: true }) - - // The JavaScript selects its host at runtime; only native addons and patches vary. - if (bundledSourceDir) { - for (const filename of bundledFilenames) { - copyFileSync(join(bundledSourceDir, filename), join(outDir, filename)) - } - } else { - await buildRelayBundles(outDir) - bundledSourceDir = outDir - bundledFilenames = readdirSync(outDir) - } - - if (isWindowsRelayPlatform(platform)) { - copyFileSync( - NODE_PTY_CONSOLE_LIST_PATCH_SOURCE, - join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) - ) - copyFileSync( - NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE, - join(outDir, NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME) - ) - } - copyFileSync( - NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE, - join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME) - ) - stageRelayWindowsProcessTreeAddon({ - platform, - outDir, - buildDir: WINDOWS_PROCESS_TREE_BUILD_DIR, - requiredArches: REQUIRED_ADDON_ARCHES - }) - - // Why: include a content hash so the deploy check detects code changes even - // when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a - // companion-only change still selects a fresh immutable relay directory. - const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) +function stampVersion(bundleFile, versionFile) { const hash = createHash('sha256') - for (const filename of expected) { - const artifactPath = join(outDir, filename) - if (!existsSync(artifactPath)) { - throw new Error( - `Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` + - 'Add the build step, or drop it from src/shared/relay-artifacts.ts.' - ) - } - hash.update(readFileSync(artifactPath)) - } - // Why hashed only when present: a relay carrying the native addon answers - // differently from one that falls back to the scan, so the two must not share - // an immutable directory -- but a build without it is still valid. - for (const filename of relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))) { - const artifactPath = join(outDir, filename) - if (existsSync(artifactPath)) { - hash.update(readFileSync(artifactPath)) - } - } - const contentHash = hash.digest('hex').slice(0, 12) - - // Close the loop: an artifact emitted here but absent from the manifest would - // ship unhashed and unprobed — exactly how the WSL helper went missing. - const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME) - const declared = [ - ...expected, - ...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)) - ] - const undeclared = emitted.filter((name) => !declared.includes(name)) - if (undeclared.length > 0) { - throw new Error( - `Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` + - 'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.' - ) - } - writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`) - - console.log(`Built relay for ${platform} → ${outDir}/relay.js`) + .update(readFileSync(join(outDir, bundleFile))) + .digest('hex') + .slice(0, 12) + writeFileSync(join(outDir, versionFile), `${RELAY_VERSION}+${hash}`) } -// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL -// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher), -// so a single platform-independent bundle suffices; it ships inside the -// Windows app via the same out/relay extraResources mapping. -{ - const wslHookEntry = join(ROOT, 'src', 'wsl-guest', 'wsl-agent-hook-relay.ts') - const wslBrowserNetworkEntry = join(ROOT, 'src', 'wsl-guest', 'wsl-browser-network-relay.ts') - const outDir = join(OUT_ROOT, 'wsl') - mkdirSync(outDir, { recursive: true }) - await build({ - entryPoints: [wslHookEntry], - alias: JSONC_PARSER_ESM_ALIAS, - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'wsl-agent-hook-relay.js'), - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js')) - const hash = createHash('sha256').update(content).digest('hex').slice(0, 12) - writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`) - console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`) +await bundle('src/wsl-guest/wsl-agent-hook-relay.ts', 'wsl-agent-hook-relay.js', { + alias: JSONC_PARSER_ESM_ALIAS +}) +stampVersion('wsl-agent-hook-relay.js', '.version') +console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`) - await build({ - entryPoints: [wslBrowserNetworkEntry], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, 'wsl-browser-network-relay.js'), - sourcemap: false, - minify: true, - define: { - 'process.env.NODE_ENV': '"production"' - } - }) - const browserNetworkContent = readFileSync(join(outDir, 'wsl-browser-network-relay.js')) - const browserNetworkHash = createHash('sha256') - .update(browserNetworkContent) - .digest('hex') - .slice(0, 12) - writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`) - console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`) +await bundle('src/wsl-guest/wsl-browser-network-relay.ts', 'wsl-browser-network-relay.js') +stampVersion('wsl-browser-network-relay.js', '.browser-network-version') +console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`) - // Why here, not the relay dirs: only the desktop runs it, inside WSL; SSH hosts never upload it. - await build({ - entryPoints: [join(ROOT, 'src/main/claude-accounts/claude-profile-wsl-entry.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: join(outDir, WSL_CLAUDE_PROFILE_HELPER_FILENAME), - minify: true, - define: { 'process.env.NODE_ENV': '"production"' } - }) -} +await bundle( + 'src/main/claude-accounts/claude-profile-wsl-entry.ts', + WSL_CLAUDE_PROFILE_HELPER_FILENAME +) +await bundle( + 'src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts', + RELAY_OPENCODE_SQLITE_READER_FILENAME, + { external: ['electron'] } +) -console.log('Relay build complete.') +console.log('WSL guest build complete.') diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 3d9d93aaed20..a0950170d4ec 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -40,6 +40,20 @@ function defaultOutputPath(projectRoot) { return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe') } +// Why explicit triples: the orcad template ships both, cross-compiled from one x64 runner. +const RUST_TARGETS = { x64: 'x86_64-pc-windows-msvc', arm64: 'aarch64-pc-windows-msvc' } + +export function windowsCliLauncherRustTarget(arch) { + if (arch === undefined) { + return null + } + const target = RUST_TARGETS[arch] + if (!target) { + throw new Error(`Unsupported Windows CLI launcher architecture: ${arch}`) + } + return target +} + function readArg(name) { const index = process.argv.indexOf(name) return index !== -1 ? process.argv[index + 1] : undefined @@ -61,6 +75,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const { version } = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) // Throws on a version the PE cannot represent, before anything is compiled. windowsCliLauncherFileVersion(version) + const rustTarget = windowsCliLauncherRustTarget(readArg('--arch')) const fingerprint = windowsCliLauncherFingerprint( [ join(crateRoot, 'src', 'main.rs'), @@ -71,7 +86,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], - version + rustTarget ? `${version}\0${rustTarget}` : version ) const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) @@ -92,7 +107,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) '--manifest-path', manifestPath, '--target-dir', - targetDirectory + targetDirectory, + ...(rustTarget ? ['--target', rustTarget] : []) ], { cwd: crateRoot, @@ -119,7 +135,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - const builtPath = join(targetDirectory, 'release', 'orca.exe') + const builtPath = join( + targetDirectory, + ...(rustTarget ? [rustTarget] : []), + 'release', + 'orca.exe' + ) const vcRuntimeImports = findDynamicVcRuntimeImports( readPeImportedDllNames(readFileSync(builtPath)) ) diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 0e06cafd22eb..236bafe7443a 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -15,6 +15,7 @@ import { describe, expect, it } from 'vitest' import { shouldReuseCompiledWindowsCliLauncher, windowsCliLauncherFileVersion, + windowsCliLauncherRustTarget, windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' @@ -245,6 +246,81 @@ describe('Windows CLI launcher', () => { } }) + it('cross-compiles only the architectures the orcad template ships', () => { + expect(windowsCliLauncherRustTarget(undefined)).toBeNull() + expect(windowsCliLauncherRustTarget('x64')).toBe('x86_64-pc-windows-msvc') + expect(windowsCliLauncherRustTarget('arm64')).toBe('aarch64-pc-windows-msvc') + expect(() => windowsCliLauncherRustTarget('ia32')).toThrow('Unsupported') + }) + + itWindows('runs a server profile CLI on pinned Node with argv and profile intact', () => { + const root = mkdtempSync(join(tmpdir(), 'orca server launcher ')) + try { + const profile = join(root, 'server profile') + const launcherPath = join(profile, 'cli', 'bin', 'orca.exe') + const cliPath = join(root, 'server slot', 'out', 'cli', 'index.js') + mkdirSync(dirname(cliPath), { recursive: true }) + writeFileSync( + cliPath, + `process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), + profile: process.env.ORCA_USER_DATA_PATH, + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, + nodeOptions: process.env.NODE_OPTIONS ?? null +}))\n`, + 'utf8' + ) + const build = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], + { cwd: projectRoot, encoding: 'utf8' } + ) + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + writeFileSync( + join(dirname(launcherPath), 'orca-launcher.cfg'), + `node=${process.execPath}\ncli=${cliPath}\nuser-data=${profile}\n` + ) + + const inherited = { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: '--require C:\\missing\\preload.js', + ORCA_USER_DATA_PATH: 'C:\\another\\owner', + ORCA_TEST_LAUNCHER: launcherPath + } + const expected = (body) => ({ + argv: ['orchestration', 'send', '--body', body, '--json'], + profile, + electronRunAsNode: null, + nodeOptions: null + }) + // Windows PowerShell 5.1 itself drops embedded double quotes, so its body has none. + const shellBody = 'line one & %PATH% ^caret\n\nline three' + const powershell = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json' + ], + { encoding: 'utf8', env: { ...inherited, ORCA_TEST_BODY: shellBody } } + ) + expect(powershell.status, powershell.stderr).toBe(0) + expect(JSON.parse(powershell.stdout)).toEqual(expected(shellBody)) + + const body = 'line one "quoted" \\"escaped\\" & %PATH% ^caret\\\n\nline three\\' + const direct = spawnSync(launcherPath, expected(body).argv, { + encoding: 'utf8', + env: inherited + }) + expect(direct.status, direct.stderr).toBe(0) + expect(JSON.parse(direct.stdout)).toEqual(expected(body)) + } finally { + removeFixtureTree(root) + } + }) + itWindows('survives an inherited environment block containing PATH and Path', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher ')) try { diff --git a/config/scripts/check-owner-routing-ratchet.mjs b/config/scripts/check-owner-routing-ratchet.mjs index c3304373fa5c..454b1931225a 100644 --- a/config/scripts/check-owner-routing-ratchet.mjs +++ b/config/scripts/check-owner-routing-ratchet.mjs @@ -4,33 +4,370 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' -// Ratchet for renderer code that routes host-owned work by the "Active Server" focus setting -// instead of by the resource's own host. The three helpers are counted together so renaming one -// into another never lowers the count. Per-file counts may only go down. +// Two ratchets for renderer code that routes host-owned work by the "Active Server" setting +// instead of by the resource's own host. Per-file counts may only go down. +// - Owner routing: the three focus-routing helpers, counted together so renaming one into another +// never lowers the count. +// - Focus reads: every read of the setting plus every call of an exported function that reads it +// for the caller (discovered, not hand-listed), so swapping one form for another never lowers it. -const BASELINE_PATH = 'config/owner-routing-baseline.txt' const SCAN_ROOT = 'src/renderer/src' -const HELPERS = '(?:getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner)' +const HELPER_NAMES = 'getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner' +const HELPERS = `(?:${HELPER_NAMES})` const IMPORT_EXPORT_LIST = /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\}/g // Calls and value uses (`.map(helper)`); definitions and type queries are not routing. const FOCUS_ROUTING_USE = new RegExp(`(?` are indexed types (`GlobalSettings['…']`), not reads. +const SETTING_MEMBER_READ = + /\??\.\s*activeRuntimeEnvironmentId\b|(?)\s*)\[\s*['"]activeRuntimeEnvironmentId['"]\s*\]/g +// `const { activeRuntimeEnvironmentId } = s`, `{ activeRuntimeEnvironmentId: id }: T = s` or nested +// `{ settings: { activeRuntimeEnvironmentId } } = state`; object literals and `({ … }) =>` +// parameters are not reads of the setting. +const SETTING_DESTRUCTURE_READ = + /\{[^{}]*\bactiveRuntimeEnvironmentId\b[^{}]*\}(?:\s*\})*\s*(?::[^=;{}]*)?=(?![=>])/g +// A top-level declaration: `[export] [async] function name` or `[export] const|let name =`. +const TOP_LEVEL_DECLARATION = + /^(export\s+)?(?:default\s+)?(?:async\s+)?(?:function\s*\*?\s*([\w$]+)|(?:const|let)\s+([\w$]+)\b)/gm +// Any new statement at column 0 ends a declaration; closing brackets and continuations do not. +const TOP_LEVEL_BOUNDARY = /^[^\s)}\]]/gm + +// Member calls only, so dotted string keys (`'auto.hooks.useX.abc'`) are not uses. +const MEMBER_NAME = /\.\s*([\w$]+)\s*(?:\?\.\s*)?\(/g + +function readerUsePattern(names) { + return names.size === 0 + ? null + : new RegExp( + `(? match.index) + return [...sourceText.matchAll(TOP_LEVEL_DECLARATION)].map((match) => { + const end = boundaries.find((index) => index > match.index) ?? sourceText.length + return { + name: match[2] ?? match[3], + exported: Boolean(match[1]), + text: sourceText.slice(match.index, end) + } + }) +} + +// Statements that hand a value on: `return …` or an arrow's expression body, each running to the +// first newline outside brackets. Object-literal results (`=> ({ … })`, `return { … }`) are +// skipped: slice creators and hooks return bags of actions, which pulls in the whole store. +const VALUE_STATEMENT_START = /\breturn\s+(?!\(?\s*\{)|=>\s*(?!\(?\s*\{)/g + +function statementsFrom(body, start) { + const statements = [] + for (const match of body.matchAll(start)) { + let depth = 0 + let end = match.index + match[0].length + for (; end < body.length; end += 1) { + const char = body[end] + if ('([{'.includes(char)) { + depth += 1 + } else if (')]}'.includes(char)) { + if (depth === 0) { + break + } + depth -= 1 + } else if (char === '\n' && depth === 0) { + break + } + } + statements.push({ name: match[1], text: body.slice(match.index, end) }) + } + return statements +} + +// `const id = reader(…)` counts only when `id` is then returned (assign-then-return). +const LOCAL_ASSIGNMENT_START = /\b(?:const|let)\s+([\w$]+)\s*(?::[^=\n]*)?=(?![=>])/g + +function valueStatements(body) { + const returned = statementsFrom(body, VALUE_STATEMENT_START).map(({ text }) => text) + const returnedIds = new Set(returned.join('\n').match(/[\w$]+/g)) + const assigned = statementsFrom(body, LOCAL_ASSIGNMENT_START) + .filter(({ name }) => returnedIds.has(name)) + .map(({ text }) => text) + return [...returned, ...assigned].join('\n') +} + +const IMPORT_STATEMENT = + /\bimport\s+(?:type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g +const REEXPORT_STATEMENT = /\bexport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g +// `const { reader } = (await import('…'))` and `import('…').then(({ reader }) =>` bind like a +// named import; `{ reader: local }` renames like `as`. +const DYNAMIC_IMPORT_DESTRUCTURE = + /\{([^{}]*)\}\s*=\s*\(?\s*await\s+import\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\))?/g +const DYNAMIC_IMPORT_THEN = + /\bimport\(\s*['"]([^'"]+)['"]\s*\)\s*\.then\(\s*(?:async\s*)?\(\s*\{([^{}]*)\}/g +const STAR_REEXPORT_STATEMENT = /\bexport\s+\*\s+from\s*['"]([^'"]+)['"]/g +const LOCAL_EXPORT_LIST = /\bexport\s+\{([^}]*)\}(?!\s*from)/g + +function specifiers(list) { + return list + .split(',') + .map((part) => part.trim().replace(/^type\s+/, '')) + .filter(Boolean) + .map((part) => { + const [imported, local = imported] = part.split(/\s+as\s+|\s*:\s*/) + return { imported: imported.trim(), local: local.trim() } + }) +} + +function resolveModule(spec, fromRel, files) { + let base + if (spec.startsWith('@/')) { + base = `${SCAN_ROOT}/${spec.slice(2)}` + } else if (spec.startsWith('.')) { + base = path.posix.join(path.posix.dirname(fromRel), spec) + } else { + return null + } + base = base.replace(/\.(?:m?js|tsx?)$/, '') + return ( + ['.ts', '.tsx', '/index.ts', '/index.tsx'].map((ext) => base + ext).find((c) => files.has(c)) ?? + null + ) +} + +function parseModule(rel, text, files) { + const imports = new Map() + for (const [, list, spec] of [ + ...text.matchAll(IMPORT_STATEMENT), + ...text.matchAll(DYNAMIC_IMPORT_DESTRUCTURE), + ...[...text.matchAll(DYNAMIC_IMPORT_THEN)].map(([all, from, names]) => [all, names, from]) + ]) { + const from = resolveModule(spec, rel, files) + for (const { imported, local } of specifiers(list)) { + imports.set(local, { from, name: imported }) + } + } + const reexports = new Map() + for (const [, list, spec] of text.matchAll(REEXPORT_STATEMENT)) { + const from = resolveModule(spec, rel, files) + for (const { imported, local } of specifiers(list)) { + reexports.set(local, { from, name: imported }) + } + } + const starFrom = [...text.matchAll(STAR_REEXPORT_STATEMENT)].map(([, spec]) => + resolveModule(spec, rel, files) + ) + const listed = new Map() + for (const [, list] of text.matchAll(LOCAL_EXPORT_LIST)) { + for (const { imported, local } of specifiers(list)) { + listed.set(imported, local) + } + } + const declarations = topLevelDeclarations(text.replace(IMPORT_EXPORT_LIST, '')).map((decl) => ({ + ...decl, + exportedAs: decl.exported ? decl.name : (listed.get(decl.name) ?? null) + })) + return { imports, reexports, starFrom, declarations } +} + +/** + * Functions that read the setting for their caller, keyed by defining file so a same-named + * function elsewhere (a test harness's `useAppStore`) is not mistaken for one. Seeds, then every + * exported top-level function whose body reads the setting or calls a seed, then, to a fixpoint, + * every one that returns a reader's result. Hand lists miss look-alikes such as a copy of + * `getActiveRuntimeTarget` under another name; discovery does not. Following every call instead + * of returned values pulls in most of the renderer through the store. + */ +export function discoverFocusReaders(sources) { + const files = new Set(sources.keys()) + const modules = new Map([...sources].map(([rel, text]) => [rel, parseModule(rel, text, files)])) + const seeds = new Set(SEED_FOCUS_READERS) + const seedUse = readerUsePattern(seeds) + const readerKeys = new Set() + + const resolveExport = (rel, name, depth = 0) => { + const mod = rel ? modules.get(rel) : null + if (!mod || depth > 8) { + return null + } + if (mod.declarations.some((decl) => decl.exportedAs === name)) { + return `${rel}#${name}` + } + const re = mod.reexports.get(name) + if (re) { + return resolveExport(re.from, re.name, depth + 1) + } + for (const from of mod.starFrom) { + const key = resolveExport(from, name, depth + 1) + if (key) { + return key + } + } + return null + } + const resolveLocal = (rel, name) => { + const mod = modules.get(rel) + if (mod.declarations.some((decl) => decl.exportedAs && decl.name === name)) { + return `${rel}#${mod.declarations.find((decl) => decl.name === name).exportedAs}` + } + const imported = mod.imports.get(name) + return imported ? resolveExport(imported.from, imported.name) : null + } + const isReader = (rel, name) => seeds.has(name) || readerKeys.has(resolveLocal(rel, name)) + + const pending = [] + for (const [rel, mod] of modules) { + for (const { name, exportedAs, text } of mod.declarations) { + // Components render; a `` that reads focus inside is not a read by its parent. + if (!exportedAs || /^[A-Z]/.test(exportedAs)) { + continue + } + seedUse.lastIndex = 0 + if (countSettingReads(text) > 0 || seedUse.test(text)) { + readerKeys.add(`${rel}#${exportedAs}`) + continue + } + const statements = valueStatements(text) + const passed = new Set(statements.match(/[\w$]+/g)) + passed.delete(name) + const members = [...statements.matchAll(MEMBER_NAME)].map(([, member]) => member) + pending.push({ rel, key: `${rel}#${exportedAs}`, passed: [...passed], members }) + } + } + // Exported reader names, for `ns.reader(…)` through a namespace or dynamic import. + const memberNames = new Set(seeds) + const addReader = (key) => { + readerKeys.add(key) + memberNames.add(key.slice(key.indexOf('#') + 1)) + } + for (const key of readerKeys) { + addReader(key) + } + let changed = true + while (changed) { + changed = false + for (let i = pending.length - 1; i >= 0; i -= 1) { + const { rel, key, passed, members } = pending[i] + if ( + passed.some((id) => isReader(rel, id)) || + members.some((member) => memberNames.has(member)) + ) { + addReader(key) + pending.splice(i, 1) + changed = true + } + } + } + + const namesByFile = new Map() + return { + has: (name) => memberNames.has(name), + memberNames, + /** Names that refer to a reader inside `rel`: seeds, its own readers and imported ones. */ + namesFor(rel) { + if (!namesByFile.has(rel)) { + const mod = modules.get(rel) + const local = [ + ...(mod?.declarations.map((decl) => decl.name) ?? []), + ...(mod?.imports.keys() ?? []) + ] + namesByFile.set( + rel, + new Set([...seeds, ...local.filter((name) => mod && isReader(rel, name))]) + ) + } + return namesByFile.get(rel) + } + } +} + +/** + * Reads of the setting (member, element and destructuring reads) plus every use of a name that + * refers to a reader. Object-literal keys are writes and are not counted. + */ +export function countFocusSettingReads( + sourceText, + readerNames = new Set(SEED_FOCUS_READERS), + memberNames = new Set() +) { + const body = sourceText + .replace(IMPORT_EXPORT_LIST, '') + // A placeholder, so a dangling `const` does not read as a declaration of the next call. + .replace(DYNAMIC_IMPORT_DESTRUCTURE, 'dynamicImport') + .replace(DYNAMIC_IMPORT_THEN, 'dynamicImport') + const pattern = readerUsePattern(readerNames) + // `ns.reader(…)` through a namespace or dynamic import; names already counted bare are skipped. + const members = [...body.matchAll(MEMBER_NAME)].filter( + ([, member]) => memberNames.has(member) && !readerNames.has(member) + ).length + return countSettingReads(body) + (pattern ? (body.match(pattern)?.length ?? 0) : 0) + members +} + +/** An alias hides later calls from the name-based helper count, so it is refused outright. */ export function hasFocusRoutingAlias(sourceText) { - return (sourceText.match(IMPORT_EXPORT_LIST) ?? []).some((list) => FOCUS_ROUTING_ALIAS.test(list)) + const alias = new RegExp(`\\b(?:${SEED_FOCUS_READERS.join('|')})\\s+as\\b`) + return (sourceText.match(IMPORT_EXPORT_LIST) ?? []).some((list) => alias.test(list)) } +export const RATCHETS = [ + { + name: 'owner-routing', + baselinePath: 'config/owner-routing-baseline.txt', + count: (text) => countFocusRoutingCalls(text), + header: [ + '# Renderer call sites that route by the Active Server focus setting:', + '# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.', + '# The helpers are deleted and this list is empty: any new use fails lint. Route by the', + "# resource's owner; only a creation flow with no source row may use defaultCreationHost." + ], + advice: + "Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting." + }, + { + name: 'focus-setting-read', + baselinePath: 'config/focus-setting-read-baseline.txt', + count: (text, rel, readers) => + countFocusSettingReads(text, readers.namesFor(rel), readers.memberNames), + header: [ + '# Renderer reads of the Active Server setting (member, element and destructuring reads of', + '# activeRuntimeEnvironmentId) plus calls of exported functions that read it, per file.', + '# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read', + '# the default host, through defaultCreationHost. Everything else routes by the owner.', + '# Prune after removing reads: pnpm check:owner-routing-ratchet --prune' + ], + advice: + "Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost." + } +] + export function isScannedPath(rel) { return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel) } -/** ` ` lines; `#` comments and blanks ignored. */ +/** ` [# note]` lines; `#` comment lines and blanks ignored. */ export function parseBaseline(text) { const counts = new Map() for (const raw of text.split('\n')) { @@ -44,19 +381,26 @@ export function parseBaseline(text) { return counts } -export function formatBaseline(counts) { - const header = [ - '# Renderer call sites that route by the Active Server focus setting:', - '# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.', - '# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead.', - '# Prune after removing sites: pnpm check:owner-routing-ratchet --prune', - '' - ].join('\n') +/** Trailing `# note` per row, so a pruned baseline keeps why an entry is still allowed. */ +export function parseBaselineNotes(text) { + const notes = new Map() + for (const raw of text.split('\n')) { + const match = /^\s*\d+\s+(\S+)\s+#\s*(.+?)\s*$/.exec(raw) + if (match) { + notes.set(match[1], match[2]) + } + } + return notes +} + +export function formatBaseline(counts, header = RATCHETS[0].header, notes = new Map()) { const rows = [...counts] .filter(([, count]) => count > 0) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([file, count]) => `${count} ${file}`) - return `${header}${rows.join('\n')}\n` + .map(([file, count]) => + notes.has(file) ? `${count} ${file} # ${notes.get(file)}` : `${count} ${file}` + ) + return `${[...header, ''].join('\n')}${rows.join('\n')}${rows.length > 0 ? '\n' : ''}` } /** `grown`: above baseline (fails). `shrunk`: below baseline, must be pruned so it cannot regrow. */ @@ -76,82 +420,120 @@ export function diffCounts(current, baseline) { return { grown: grown.sort(byFile), shrunk: shrunk.sort(byFile) } } -export function collectCurrentCounts(root = process.cwd()) { - const tracked = execFileSync('git', ['ls-files', SCAN_ROOT], { +function readTrackedSources(root, scanRoot) { + const tracked = execFileSync('git', ['ls-files', scanRoot], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) .split('\n') .filter((rel) => rel && isScannedPath(rel)) - const counts = new Map() - const aliased = [] + const sources = new Map() for (const rel of tracked) { - let source try { - source = fs.readFileSync(path.join(root, rel), 'utf8') + sources.set(rel, fs.readFileSync(path.join(root, rel), 'utf8')) } catch { + // Deleted in the working tree but still tracked. + } + } + return sources +} + +// One read and one discovery per root per run; `--prune` then `main` reuse them. +const scanCache = new Map() + +function scanSources(root) { + if (!scanCache.has(root)) { + const sources = new Map( + FOCUS_SCAN_ROOTS.flatMap((scanRoot) => [...readTrackedSources(root, scanRoot)]) + ) + scanCache.set(root, { sources, readers: discoverFocusReaders(sources) }) + } + return scanCache.get(root) +} + +export function collectCurrentCounts(root = process.cwd(), count = RATCHETS[0].count) { + const { sources, readers } = scanSources(root) + const counts = new Map() + const aliased = [] + for (const [rel, source] of sources) { + if (!rel.startsWith(`${SCAN_ROOT}/`)) { continue } - const count = countFocusRoutingCalls(source) - if (count > 0) { - counts.set(rel, count) + const found = count(source, rel, readers) + if (found > 0) { + counts.set(rel, found) } if (hasFocusRoutingAlias(source)) { aliased.push(rel) } } - return { counts, aliased } + return { counts, aliased, readers } } function total(counts) { return [...counts.values()].reduce((sum, count) => sum + count, 0) } -export function main(root = process.cwd()) { - const baselineFile = path.join(root, BASELINE_PATH) +function checkRatchet(root, ratchet) { + const baselineFile = path.join(root, ratchet.baselinePath) if (!fs.existsSync(baselineFile)) { - console.error(`::error::Missing ${BASELINE_PATH}.`) - return 1 + console.error(`::error::Missing ${ratchet.baselinePath}.`) + return { ok: false, total: 0 } } const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) - const { counts: current, aliased } = collectCurrentCounts(root) + const { counts: current } = collectCurrentCounts(root, ratchet.count) const { grown, shrunk } = diffCounts(current, baseline) - for (const file of aliased) { + for (const { file, now, allowed } of grown) { console.error( - `::error file=${file}::A focus-routing helper is imported or exported under another name, which hides its calls from this ratchet. Use the original name.` + `::error file=${file}::${ratchet.name}: ${now} use(s), baseline allows ${allowed}. ${ratchet.advice}` ) } - for (const { file, now, allowed } of grown) { + for (const { file, now, allowed } of shrunk) { console.error( - `::error file=${file}::${now} focus-routed call(s), baseline allows ${allowed}. Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting.` + `::error file=${file}::${ratchet.name}: ${now} use(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune` ) } - for (const { file, now, allowed } of shrunk) { + return { ok: grown.length === 0 && shrunk.length === 0, total: total(current) } +} + +export function main(root = process.cwd()) { + const { aliased } = collectCurrentCounts(root) + for (const file of aliased) { console.error( - `::error file=${file}::${now} focus-routed call(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune` + `::error file=${file}::A focus-routing helper is imported or exported under another name, which hides its calls from this ratchet. Use the original name.` ) } - if (aliased.length > 0 || grown.length > 0 || shrunk.length > 0) { - return 1 + let ok = aliased.length === 0 + for (const ratchet of RATCHETS) { + const result = checkRatchet(root, ratchet) + ok = ok && result.ok + if (result.ok) { + console.log(`${ratchet.name} ratchet OK — ${result.total} use(s).`) + } } - console.log(`Owner-routing ratchet OK — ${total(current)} focus-routed call site(s).`) - return 0 + return ok ? 0 : 1 } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const root = process.cwd() - if (process.argv[2] === '--prune') { - const baselineFile = path.join(root, BASELINE_PATH) - const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) - const { counts: current } = collectCurrentCounts(root) +export function prune(root = process.cwd()) { + for (const ratchet of RATCHETS) { + const baselineFile = path.join(root, ratchet.baselinePath) + const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' + const baseline = parseBaseline(text) + const { counts: current } = collectCurrentCounts(root, ratchet.count) // Lowers entries only; growth still has to be fixed in the code. const pruned = new Map( [...baseline].map(([file, allowed]) => [file, Math.min(allowed, current.get(file) ?? 0)]) ) - fs.writeFileSync(baselineFile, formatBaseline(pruned)) - console.log(`Pruned ${BASELINE_PATH} to ${total(pruned)} call site(s).`) - process.exit(main(root)) + fs.writeFileSync(baselineFile, formatBaseline(pruned, ratchet.header, parseBaselineNotes(text))) + console.log(`Pruned ${ratchet.baselinePath} to ${total(pruned)} use(s).`) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = process.cwd() + if (process.argv[2] === '--prune') { + prune(root) } process.exit(main(root)) } diff --git a/config/scripts/check-owner-routing-ratchet.test.mjs b/config/scripts/check-owner-routing-ratchet.test.mjs index c33b9304121e..8ffb9bd5da29 100644 --- a/config/scripts/check-owner-routing-ratchet.test.mjs +++ b/config/scripts/check-owner-routing-ratchet.test.mjs @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest' import { countFocusRoutingCalls, + countFocusSettingReads, diffCounts, + discoverFocusReaders, formatBaseline, hasFocusRoutingAlias, isScannedPath, - parseBaseline + parseBaseline, + parseBaselineNotes } from './check-owner-routing-ratchet.mjs' describe('countFocusRoutingCalls', () => { @@ -39,6 +42,205 @@ describe('countFocusRoutingCalls', () => { }) }) +describe('countFocusSettingReads', () => { + it('counts setting reads, the helpers and the creation default together', () => { + const src = [ + "import { defaultCreationHost } from './default-creation-host'", + 'const a = settings?.activeRuntimeEnvironmentId', + 'const b = state.settings.activeRuntimeEnvironmentId', + "const c = settings['activeRuntimeEnvironmentId']", + 'const d = getActiveRuntimeTarget(settings)', + 'const e = defaultCreationHost(settings)', + 'export function defaultCreationHost(settings) {}' + ].join('\n') + expect(countFocusSettingReads(src)).toBe(5) + }) + + it('discovers readers through imports, so a look-alike or a wrapper lowers nothing', () => { + const readers = discoverFocusReaders( + new Map([ + [ + 'src/shared/execution-host.ts', + 'export function getSettingsFocusedExecutionHostId(settings) {\n return settings?.activeRuntimeEnvironmentId\n}\n' + ], + [ + 'src/renderer/src/a.ts', + [ + "import { getSettingsFocusedExecutionHostId } from '../../shared/execution-host'", + 'export function getAutomationListTarget(settings) {', + ' const id = settings?.activeRuntimeEnvironmentId?.trim()', + " return id ? { kind: 'environment', environmentId: id } : { kind: 'local' }", + '}', + 'export const cacheKey = (s) => `k:${getActiveRuntimeTarget(s).kind}`', + 'export function browserHostId(state, override) {', + ' return override ?? getSettingsFocusedExecutionHostId(state.settings)', + '}', + 'export function wrappedTwice(state) {', + ' return browserHostId(state, null)', + '}', + 'export function ownerTarget(row) {', + ' return row.owner', + '}', + 'export function SettingsPane() {', + ' return getAutomationListTarget(null)', + '}' + ].join('\n') + ], + [ + 'src/renderer/src/harness.ts', + 'export const ownerTarget = (s) => s.activeRuntimeEnvironmentId' + ], + [ + 'src/renderer/src/b.ts', + [ + "import { browserHostId, wrappedTwice, ownerTarget, SettingsPane } from './a'", + 'const a = wrappedTwice(state)', + 'const b = ownerTarget(row)', + 'const c = ' + ].join('\n') + ] + ]) + ) + const inA = readers.namesFor('src/renderer/src/a.ts') + for (const name of ['getAutomationListTarget', 'cacheKey', 'browserHostId', 'wrappedTwice']) { + expect(inA.has(name)).toBe(true) + } + expect(inA.has('ownerTarget')).toBe(false) + expect(inA.has('SettingsPane')).toBe(false) + // `ownerTarget` from `./a` is not the harness's same-named reader. + expect( + [...readers.namesFor('src/renderer/src/b.ts')].filter((n) => !n.startsWith('get')) + ).toEqual(expect.arrayContaining(['browserHostId', 'wrappedTwice'])) + expect(readers.namesFor('src/renderer/src/b.ts').has('ownerTarget')).toBe(false) + expect( + countFocusSettingReads( + 'const t = runtimeTargetForOwnerHostId(getSettingsFocusedExecutionHostId(s))', + readers.namesFor('src/renderer/src/a.ts') + ) + ).toBe(1) + expect( + countFocusSettingReads( + "import { wrappedTwice } from './a'\nconst t = wrappedTwice(state)", + readers.namesFor('src/renderer/src/b.ts') + ) + ).toBe(1) + expect(countFocusSettingReads('export const cacheKey = (s) => s', inA)).toBe(0) + }) + + it('counts a reader called through a namespace or dynamic import', () => { + const readers = discoverFocusReaders( + new Map([ + [ + 'src/renderer/src/routing.ts', + 'export function focusedHostId(s) {\n return s.activeRuntimeEnvironmentId\n}\n' + ], + [ + 'src/renderer/src/wrap.ts', + "import * as rr from './routing'\nexport function hostOf(s) {\n return rr.focusedHostId(s)\n}\n" + ], + [ + 'src/renderer/src/use.ts', + "const { focusedHostId } = await import('./routing')\nconst a = focusedHostId(s)\n" + ] + ]) + ) + expect(readers.has('hostOf')).toBe(true) + expect( + countFocusSettingReads( + "import * as rr from './routing'\nconst a = rr.focusedHostId(s)\nt('auto.rr.focusedHostId.x')", + readers.namesFor('src/renderer/src/other.ts'), + readers.memberNames + ) + ).toBe(1) + expect( + countFocusSettingReads( + "const { focusedHostId } = await import('./routing')\nconst a = focusedHostId(s)\n", + readers.namesFor('src/renderer/src/use.ts'), + readers.memberNames + ) + ).toBe(1) + for (const [rel, text] of [ + ['src/renderer/src/r1.ts', "const { focusedHostId: g } = await import('./routing')\ng(s)"], + [ + 'src/renderer/src/r2.ts', + "const { focusedHostId } = (await import('./routing'))\nfocusedHostId(s)" + ], + [ + 'src/renderer/src/r3.ts', + "void import('./routing').then(({ focusedHostId }) => focusedHostId(s))" + ] + ]) { + const withFile = discoverFocusReaders( + new Map([ + [ + 'src/renderer/src/routing.ts', + 'export function focusedHostId(s) {\n return s.activeRuntimeEnvironmentId\n}\n' + ], + [rel, text] + ]) + ) + expect(countFocusSettingReads(text, withFile.namesFor(rel), withFile.memberNames)).toBe(1) + } + }) + + it('discovers a reader assigned to a local that is then returned', () => { + const readers = discoverFocusReaders( + new Map([ + [ + 'src/renderer/src/focus.ts', + 'export function focusedId(s) {\n return s.activeRuntimeEnvironmentId\n}\n' + ], + [ + 'src/renderer/src/hook.ts', + [ + "import { focusedId } from './focus'", + 'export function useTarget(s) {', + ' const id = focusedId(s)', + ' const unrelated = focusedId(s)', + ' return id ? { id } : null', + '}', + 'export function sideEffectOnly(s) {', + ' const id = focusedId(s)', + ' log(id)', + ' return null', + '}' + ].join('\n') + ] + ]) + ) + expect(readers.namesFor('src/renderer/src/hook.ts').has('useTarget')).toBe(true) + expect(readers.namesFor('src/renderer/src/hook.ts').has('sideEffectOnly')).toBe(false) + }) + + it('counts the default scope source as a read', () => { + expect(countFocusSettingReads('linearConnect(defaultScopeSource(get().settings), key)')).toBe(1) + }) + + it('counts destructuring reads', () => { + expect(countFocusSettingReads('const { activeRuntimeEnvironmentId } = s')).toBe(1) + expect( + countFocusSettingReads('const { settings: { activeRuntimeEnvironmentId } } = state') + ).toBe(1) + expect( + countFocusSettingReads( + "const { theme, activeRuntimeEnvironmentId: id }: Pick = s" + ) + ).toBe(1) + }) + + it('does not count writes, keys, props or type positions', () => { + const src = [ + 'const owner = { activeRuntimeEnvironmentId: id }', + 'settings = { ...settings, activeRuntimeEnvironmentId: null }', + "type T = GlobalSettings['activeRuntimeEnvironmentId']", + 'updateSettings({ activeRuntimeEnvironmentId: null })', + 'const f = ({ activeRuntimeEnvironmentId }) => activeRuntimeEnvironmentId', + 'if (a === b) { x = { activeRuntimeEnvironmentId } }' + ].join('\n') + expect(countFocusSettingReads(src)).toBe(0) + }) +}) + describe('hasFocusRoutingAlias', () => { it('refuses an aliased import or re-export, which would hide its calls', () => { expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget as route } from './rpc'")).toBe( @@ -47,6 +249,7 @@ describe('hasFocusRoutingAlias', () => { expect( hasFocusRoutingAlias("export {\n settingsForRuntimeOwner as owner\n} from './target'") ).toBe(true) + expect(hasFocusRoutingAlias("import { defaultCreationHost as host } from './d'")).toBe(true) expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget } from './rpc'")).toBe(false) }) }) @@ -75,6 +278,17 @@ describe('baseline', () => { ) }) + it('keeps a row note through a prune', () => { + const text = formatBaseline( + new Map([['src/a.ts', 1]]), + ['# header'], + new Map([['src/a.ts', 'waits for V4b']]) + ) + expect(text).toBe('# header\n1 src/a.ts # waits for V4b\n') + expect(parseBaseline(text)).toEqual(new Map([['src/a.ts', 1]])) + expect(parseBaselineNotes(text)).toEqual(new Map([['src/a.ts', 'waits for V4b']])) + }) + it('fails growth, including a new file, and asks to prune shrinkage', () => { const { grown, shrunk } = diffCounts( new Map([ diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 6bee27ae9739..be51ce722d0e 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -1,45 +1,7 @@ import { pathToFileURL } from 'node:url' -export const DOCKER_SSH_E2E_SPECS = [ - 'tests/e2e/local-ssh-browser-routing.spec.ts', - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-ai-vault-session-history.spec.ts', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', - 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', - 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', - 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', - 'tests/e2e/ssh-docker-half-open-link.spec.ts', - 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts', - 'tests/e2e/ssh-docker-resource-accumulation.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-external-image-preview.spec.ts', - 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', - 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reattach-home-partition.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', - 'tests/e2e/ssh-skill-installation.spec.ts', - 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts', - 'tests/e2e/ssh-startup-local-shadow.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', - 'tests/e2e/terminal-inline-images-ssh.spec.ts', - 'tests/e2e/ssh-docker-watcher-isolation.spec.ts', - 'tests/e2e/ssh-terminal-parking.spec.ts', - 'tests/e2e/terminal-retention-budget.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/paired-startup-exec-readiness.spec.ts', - 'tests/e2e/workspace-layout-oracle-ssh.spec.ts' -] - export const NODE_NETWORK_E2E_SPEC = 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' -export const LOCALHOST_SSH_E2E_SPEC = 'tests/e2e/ssh-localhost.spec.ts' export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.ts' // Needs the packaged orcad slot, which only its own job builds. export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts' @@ -71,10 +33,14 @@ export const ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC = export const ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC = 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts' export const ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-terminal-root-owner.spec.ts' +// Run in their own job, which builds the template they need. +export const ORCAD_JOURNEY_E2E_SPECS = [ + 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts', + 'tests/e2e/ssh-orcad-workspace-tools.spec.ts', + 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts' +] export const DEDICATED_E2E_SPECS = [ - ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, - LOCALHOST_SSH_E2E_SPEC, NATIVE_IME_E2E_SPEC, ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, LAYOUT_ORACLE_HEADLESS_E2E_SPEC, @@ -93,10 +59,10 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC, ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC, ORCAD_BROWSER_DROP_OWNER_E2E_SPEC, - ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC + ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC, + ...ORCAD_JOURNEY_E2E_SPECS ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) -const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) export function selectGeneralE2eSpecs(specs) { return specs.filter((spec) => !dedicatedSpecs.has(spec)) @@ -136,7 +102,7 @@ function parseSpecs(input) { return specs } -export function classifyE2eJobs(input, sshSourceChanged = 'false') { +export function classifyE2eJobs(input) { const conservative = { e2e_run_changed: true, e2e_needs_build: true } let specs try { @@ -153,11 +119,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { e2e_run_changed: runChanged, e2e_needs_build: runChanged || - sshSourceChanged !== 'false' || specs.some( (spec) => - dockerSpecs.has(spec) || - spec === LOCALHOST_SSH_E2E_SPEC || spec === ORCAD_SERVE_MODE_SWITCH_E2E_SPEC || spec === LAYOUT_ORACLE_HEADLESS_E2E_SPEC || spec === ORCAD_AUTO_CONVERT_E2E_SPEC || @@ -174,7 +137,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC || spec === ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC || spec === ORCAD_BROWSER_DROP_OWNER_E2E_SPEC || - spec === ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC + spec === ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC || + ORCAD_JOURNEY_E2E_SPECS.includes(spec) ) } } @@ -186,9 +150,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) input += chunk } if (process.argv.includes('--job-outputs')) { - for (const [name, value] of Object.entries( - classifyE2eJobs(input, process.env.E2E_SSH_SOURCE_CHANGED ?? 'false') - )) { + for (const [name, value] of Object.entries(classifyE2eJobs(input))) { process.stdout.write(`${name}=${value}\n`) } } else { diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 500820af3886..23c3a92344bc 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -7,8 +7,6 @@ import { runProcess } from '@orca/process-host' import { classifyE2eJobs, DEDICATED_E2E_SPECS, - DOCKER_SSH_E2E_SPECS, - LOCALHOST_SSH_E2E_SPEC, NATIVE_IME_E2E_SPEC, NODE_NETWORK_E2E_SPEC, parseE2eChangedShard, @@ -49,7 +47,7 @@ it.each([ const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) +const classify = (specs) => classifyE2eJobs(JSON.stringify(specs)) it('runs real browser file-drop ownership in the template-building Docker job', () => { const spec = 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts' @@ -85,42 +83,32 @@ it('skips the general consumer only when every requested spec has a dedicated ow }) it('keeps Electron build and native prerequisites for every consumer requiring them', () => { - for (const spec of [...DOCKER_SSH_E2E_SPECS, LOCALHOST_SSH_E2E_SPEC]) { - expect(classify([spec]), spec).toEqual({ - e2e_run_changed: false, - e2e_needs_build: true - }) - } for (const spec of [NODE_NETWORK_E2E_SPEC, NATIVE_IME_E2E_SPEC]) { expect(classify([spec]), spec).toEqual({ e2e_run_changed: false, e2e_needs_build: false }) - expect(classify([spec], 'true').e2e_needs_build).toBe(true) } expect(workflow.jobs['ssh-browser-network-route'].needs).toBeUndefined() - for (const name of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation', 'ssh-localhost']) { + for (const name of ['e2e', 'changed-e2e']) { expect(workflow.jobs[name].needs, name).toEqual(['build', 'prepare-native-cache']) } }) -it('retains allocations when selection or SSH evidence is incomplete', () => { +it('retains allocations when selection evidence is incomplete', () => { for (const input of ['', '[]', 'null', '{}', '[null]', '[""]', 'malformed']) { expect(classifyE2eJobs(input), input).toEqual({ e2e_run_changed: true, e2e_needs_build: true }) } - expect(classify([NODE_NETWORK_E2E_SPEC], '').e2e_needs_build).toBe(true) }) it('preserves the requested specs across source-routed and mixed selections', () => { for (const files of [ - ['src/main/ssh/connection.ts'], ['src/main/browser/ssh-browser-network-execution-route.ts'], - ['src/main/agent-hooks/server.ts'], ['src/shared/terminal-unicode-provider.ts'], - ['tests/e2e/ssh-localhost.spec.ts', 'tests/e2e/future-unclassified.spec.ts'] + [NODE_NETWORK_E2E_SPEC, 'tests/e2e/future-unclassified.spec.ts'] ]) { const specs = selectPrE2eSpecs(files) const general = selectGeneralE2eSpecs(specs) @@ -267,7 +255,6 @@ it('classifies PR consumers in the existing detector and passes conservative all expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`) } const command = detector.steps.find((step) => step.id === 'e2e_filter').run - expect(command).toContain('E2E_SSH_SOURCE_CHANGED="$SSH_SOURCE_CHANGED"') expect(command).toContain('ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT"') expect(prWorkflow.jobs.e2e.with.run_changed_e2e).toBe( "${{ needs.code_paths.outputs.e2e_run_changed != 'false' }}" @@ -277,17 +264,6 @@ it('classifies PR consumers in the existing detector and passes conservative all ) }) -it('runs remaining SSH tests after real failures and stops them when a run is cancelled', () => { - const steps = workflow.jobs['ssh-docker-watcher-isolation'].steps - expect(steps.find((step) => step.name === 'Run remaining Docker SSH E2E').if).toBe('!cancelled()') - expect( - steps.find((step) => step.name === 'Run Docker SSH terminal parking + startup readiness E2E').if - ).toBe('!cancelled() && matrix.shard == 1') - for (const step of steps.filter((step) => step.name?.startsWith('Keep '))) { - expect(step.if).toContain('always()') - } -}) - it.each([ 'src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts', 'src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts', diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index ebff3e03a683..faae98eb8003 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1,9825 +1,11857 @@ { "unit": { - "runId": "36294142683", + "runId": "38086200856", "runAttempt": "1", - "sourceSha": "cc35c1b422e9c20197bc59aa27a71019ffc18b5b", + "sourceSha": "311827966ba81ad0e2b40bf5b98a2a5f1b6166eb", "nodeVersion": "24.21.0", + "bunVersion": "1.4.2", "metric": "module-duration-v1", "overheadMs": 0, "timings": { - "config/scripts/adhoc-build-version.test.mjs": 90, - "config/scripts/agent-status-hot-path-benchmark.test.ts": 2954, - "config/scripts/app-store-performance-plugin.test.mjs": 1255, - "config/scripts/assert-github-release-is-draft.test.mjs": 348, - "config/scripts/audit-localization-coverage.test.mjs": 412, - "config/scripts/benchmark-artifact-comparison.test.mjs": 193, - "config/scripts/benchmark-sample-summary.test.mjs": 45, - "config/scripts/build-linux-local.test.mjs": 35, - "config/scripts/build-mac-local.test.mjs": 52, - "config/scripts/build-mobile-web-app-bundle.test.mjs": 213, - "config/scripts/build-native-for-platform.test.mjs": 42, - "config/scripts/build-orcad-prebuilds.test.mjs": 85, - "config/scripts/build-windows-cli-launcher.test.mjs": 84, - "config/scripts/casting-code-quality.test.mjs": 874, - "config/scripts/check-changed-code-quality.test.mjs": 79, - "config/scripts/check-max-lines-ratchet.test.mjs": 945, - "config/scripts/check-react-doctor-changed.test.mjs": 300, - "config/scripts/check-readme-local-links.test.mjs": 520, - "config/scripts/check-reliability-gates.test.mjs": 175, - "config/scripts/check-root-directory-entries.test.mjs": 1241, - "config/scripts/check-runtime-electron-ratchet.test.mjs": 2494, - "config/scripts/check-terminal-perf-report-budgets.test.mjs": 2243, - "config/scripts/check-ts-nocheck-ratchet.test.mjs": 44, - "config/scripts/ci-cache-warmup-workflow.test.mjs": 110, - "config/scripts/ci-dependency-download-cache.test.mjs": 401, - "config/scripts/ci-e2e-shard-selection.test.mjs": 14257, - "config/scripts/ci-native-toolchain.test.mjs": 92, - "config/scripts/ci-shard-assignment.test.mjs": 308, - "config/scripts/ci-unit-timing-reporter.test.mjs": 62, - "config/scripts/cli-main-module-bundle-parity.test.ts": 594, - "config/scripts/client-hosted-browser-package-coverage.test.mjs": 124, - "config/scripts/cloud-security-history-workflow.test.mjs": 344, - "config/scripts/codex-index-heal-contract-workflow.test.mjs": 145, - "config/scripts/codex-primary-home-tripwire.test.ts": 344, - "config/scripts/computer-e2e-workflow.test.mjs": 159, - "config/scripts/computer-use-modifier-safety.test.mjs": 51, - "config/scripts/computer-use-mouse-button-routing.test.mjs": 43, - "config/scripts/computer-use-skill-guidance.test.mjs": 130, - "config/scripts/computer-use-smoke.test.mjs": 580, - "config/scripts/computer-use-windows-horizontal-scroll.test.mjs": 42, - "config/scripts/counterbalanced-benchmark-schedule.test.mjs": 47, - "config/scripts/create-draft-release.test.mjs": 72, - "config/scripts/daily-build-version.test.mjs": 87, - "config/scripts/daily-e2e-dispatch-contract.test.mjs": 168, - "config/scripts/dev-channel-base-version.test.mjs": 60, - "config/scripts/dev-channel-windows-workflow-contract.test.mjs": 141, - "config/scripts/dev-cli-terminal-wrapper.test.mjs": 34, - "config/scripts/dev-electron-bundle-cache.test.ts": 52, - "config/scripts/dev-electron-bundle-identity.test.ts": 82, - "config/scripts/electron-builder-config.test.mjs": 1072, - "config/scripts/electron-builder-mac-channel-config.test.mjs": 1002, - "config/scripts/electron-builder-markdown-associations.test.mjs": 269, - "config/scripts/electron-builder-native-rebuild.test.mjs": 41, - "config/scripts/electron-builder-runtime-resources.test.mjs": 1124, - "config/scripts/electron-builder-speech-config.test.mjs": 485, - "config/scripts/electron-runtime-floor.test.ts": 37, - "config/scripts/electron-vite-output-contract.test.ts": 1042, - "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 35, - "config/scripts/ensure-native-runtime.test.mjs": 771, + "config/scripts/adhoc-build-pinned-commit.test.mjs": 226, + "config/scripts/adhoc-build-version.test.mjs": 95, + "config/scripts/agent-state-rules-bundle.test.mjs": 327, + "config/scripts/app-store-performance-plugin.test.mjs": 1010, + "config/scripts/assert-github-release-is-draft.test.mjs": 352, + "config/scripts/audit-localization-coverage.test.mjs": 270, + "config/scripts/benchmark-artifact-comparison.test.mjs": 171, + "config/scripts/benchmark-sample-summary.test.mjs": 57, + "config/scripts/build-linux-local.test.mjs": 56, + "config/scripts/build-mac-local.test.mjs": 57, + "config/scripts/build-mobile-web-app-bundle.test.mjs": 419, + "config/scripts/build-native-for-platform.test.mjs": 52, + "config/scripts/build-orcad-prebuilds.test.mjs": 105, + "config/scripts/build-orcad-template.test.mjs": 145, + "config/scripts/build-windows-cli-launcher.test.mjs": 117, + "config/scripts/casting-code-quality.test.mjs": 854, + "config/scripts/check-changed-code-quality.test.mjs": 3252, + "config/scripts/check-daemon-protocol-crossing.test.mjs": 194, + "config/scripts/check-max-lines-ratchet.test.mjs": 658, + "config/scripts/check-node-runtime-pin.test.mjs": 696, + "config/scripts/check-owner-routing-ratchet.test.mjs": 86, + "config/scripts/check-process-host-imports.test.mjs": 301, + "config/scripts/check-react-doctor-changed.test.mjs": 229, + "config/scripts/check-readme-local-links.test.mjs": 414, + "config/scripts/check-reliability-gates.test.mjs": 216, + "config/scripts/check-root-directory-entries.test.mjs": 790, + "config/scripts/check-runtime-electron-ratchet-graph.test.mjs": 163, + "config/scripts/check-runtime-electron-ratchet.test.mjs": 3736, + "config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs": 185, + "config/scripts/check-terminal-perf-report-budgets.test.mjs": 1481, + "config/scripts/check-ts-nocheck-ratchet.test.mjs": 68, + "config/scripts/ci-closed-pr-caches.test.mjs": 123, + "config/scripts/ci-closed-pr-cancellation.test.mjs": 128, + "config/scripts/ci-dependency-download-cache.test.mjs": 557, + "config/scripts/ci-e2e-failure-summary.test.mjs": 56, + "config/scripts/ci-e2e-job-selection.test.mjs": 326, + "config/scripts/ci-e2e-shard-selection.test.mjs": 2569, + "config/scripts/ci-native-cache.test.mjs": 267, + "config/scripts/ci-native-toolchain.test.mjs": 118, + "config/scripts/ci-pnpm-store-mode.test.mjs": 550, + "config/scripts/ci-pnpm-verification-cache.test.mjs": 331, + "config/scripts/ci-runner-metrics.test.mjs": 65, + "config/scripts/ci-shard-assignment.test.mjs": 576, + "config/scripts/ci-unit-dependency-graph.test.mjs": 340, + "config/scripts/ci-unit-exclusion-coverage.test.mjs": 936, + "config/scripts/ci-unit-import-resolution-budget.test.mjs": 270, + "config/scripts/ci-unit-plan.test.mjs": 3110, + "config/scripts/ci-unit-selection-review.test.mjs": 43, + "config/scripts/ci-unit-selection.test.mjs": 276, + "config/scripts/ci-unit-sequencer.test.mjs": 318, + "config/scripts/ci-unit-timing-reporter.test.mjs": 268, + "config/scripts/cloud-security-history-workflow.test.mjs": 259, + "config/scripts/codex-primary-home-tripwire.test.ts": 359, + "config/scripts/computer-use-smoke.test.mjs": 491, + "config/scripts/counterbalanced-benchmark-schedule.test.mjs": 57, + "config/scripts/create-draft-release.test.mjs": 71, + "config/scripts/daemon-protocol-facts.test.mjs": 57, + "config/scripts/daily-build-version.test.mjs": 90, + "config/scripts/dev-channel-base-version.test.mjs": 51, + "config/scripts/dev-cli-terminal-wrapper.test.mjs": 57, + "config/scripts/dev-electron-bundle-cache.test.ts": 49, + "config/scripts/dev-electron-bundle-identity.test.ts": 59, + "config/scripts/e2e-cli-artifact-capability.test.mjs": 262, + "config/scripts/electron-builder-config.test.mjs": 906, + "config/scripts/electron-builder-mac-channel-config.test.mjs": 396, + "config/scripts/electron-builder-native-rebuild.test.mjs": 57, + "config/scripts/electron-builder-runtime-resources.test.mjs": 540, + "config/scripts/electron-builder-speech-config.test.mjs": 322, + "config/scripts/electron-runtime-floor.test.ts": 49, + "config/scripts/electron-vite-output-contract.test.ts": 741, + "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 73, + "config/scripts/ensure-native-runtime.test.mjs": 783, "config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts": 42, - "config/scripts/generate-bundled-skill-guides.test.mjs": 510, - "config/scripts/generate-runtime-required-english-catalog.test.mjs": 452, - "config/scripts/generate-skill-bundle-manifest.test.mjs": 721, - "config/scripts/generate-terminal-perf-html-report.test.mjs": 71, - "config/scripts/git-binary-compatibility-workflow.test.mjs": 153, - "config/scripts/git-pull-request-diff-base.test.mjs": 36, - "config/scripts/hang-watchdog-process-metrics.test.mjs": 34, - "config/scripts/happy-dom-mutation-observer-retention.test.ts": 384, - "config/scripts/happy-dom-offscreen-canvas.test.ts": 319, - "config/scripts/headless-serve-shutdown-matrix.test.mjs": 112, - "config/scripts/headless-serve-shutdown-workflow.test.mjs": 148, - "config/scripts/hourly-build-version.test.mjs": 136, - "config/scripts/hourly-preflight-workflow.test.mjs": 165, - "config/scripts/idle-cpu-process-sampling.test.mjs": 60, - "config/scripts/install-electron-package-binary.test.mjs": 3284, - "config/scripts/install-node-dependencies-action.test.mjs": 434, - "config/scripts/lag-probe-failures.test.mjs": 52, - "config/scripts/latest-stable-release.test.mjs": 60, - "config/scripts/lint-staged-worktree-backup.test.mjs": 436, - "config/scripts/linux-package-fixture-cache.test.mjs": 200, - "config/scripts/linux-package-maintainer-scripts.test.mjs": 19, - "config/scripts/live-freeze-bounded-history.test.mjs": 49, - "config/scripts/live-remote-bulk-open-freeze-metrics.test.mjs": 72, - "config/scripts/live-remote-freeze-rpc.test.mjs": 53, - "config/scripts/live-remote-status-watchdog.test.mjs": 285, - "config/scripts/locale-brand-prefilter.test.mjs": 263, - "config/scripts/locale-count-fragment-separator.test.mjs": 200, - "config/scripts/locale-generic-ui-terms.test.mjs": 111, - "config/scripts/locale-ko-frozen-terminal-search-keywords.test.mjs": 127, - "config/scripts/locale-ko-key-overrides.test.mjs": 95, - "config/scripts/locale-repair-catalog-missing-leaves.test.mjs": 124, - "config/scripts/locale-translation-policy-ko-round5.test.mjs": 249, - "config/scripts/locale-translation-policy.es-pr.test.mjs": 132, - "config/scripts/locale-translation-policy.es-round5.test.mjs": 118, - "config/scripts/locale-translation-policy.ja-relocalization.test.mjs": 207, - "config/scripts/locale-translation-policy.ja-round5.test.mjs": 137, - "config/scripts/locale-translation-policy.test.mjs": 283, - "config/scripts/locale-translation-policy.zh-round5.test.mjs": 290, - "config/scripts/locale-translation-policy.zh-status-bar-usage.test.mjs": 138, - "config/scripts/localization-extraction-change-scope.test.mjs": 133, - "config/scripts/localization-package-contract.test.mjs": 40, - "config/scripts/mac-build-compatibility.test.mjs": 41, - "config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs": 33, - "config/scripts/macos-computer-helper-owner-loss-processes.test.mjs": 64, - "config/scripts/macos-tcc-prompt-localization.test.mjs": 213, - "config/scripts/mobile-launch-contract-workflow.test.mjs": 439, - "config/scripts/mobile-mirrored-storage-write-path.test.mjs": 136, - "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 421, - "config/scripts/mobile-release-check-scope.test.mjs": 610, - "config/scripts/mobile-release-shell-switch-workflow.test.mjs": 227, - "config/scripts/mobile-set-native-props-web-siblings.test.mjs": 959, - "config/scripts/mobile-test-change-scope.test.mjs": 107, - "config/scripts/mobile-web-app-agent-history-render.test.mjs": 695, - "config/scripts/mobile-web-app-browser-pane-render.test.mjs": 416, - "config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs": 380, - "config/scripts/mobile-web-app-clipboard-image-resize-render.test.mjs": 544, - "config/scripts/mobile-web-app-drawer-render.test.mjs": 379, - "config/scripts/mobile-web-app-editable-host-font-size.test.mjs": 590, - "config/scripts/mobile-web-app-external-link-seam.test.mjs": 340, - "config/scripts/mobile-web-app-external-navigation-grant.test.mjs": 81, - "config/scripts/mobile-web-app-files-external-links.test.mjs": 383, - "config/scripts/mobile-web-app-files-render.test.mjs": 315, - "config/scripts/mobile-web-app-frame-budget-sweep.test.ts": 545, - "config/scripts/mobile-web-app-handoff-grants-render.test.mjs": 476, - "config/scripts/mobile-web-app-haptics-seam.test.mjs": 491, - "config/scripts/mobile-web-app-html-preview-render.test.mjs": 1035, - "config/scripts/mobile-web-app-mermaid-render.test.mjs": 567, - "config/scripts/mobile-web-app-native-parity-render.test.mjs": 554, - "config/scripts/mobile-web-app-page-closure-families.test.mjs": 75, - "config/scripts/mobile-web-app-page-grant-call-sites.test.mjs": 567, - "config/scripts/mobile-web-app-push-subsystem-closure.test.mjs": 70, - "config/scripts/mobile-web-app-render.test.mjs": 339, - "config/scripts/mobile-web-app-rich-markdown-render.test.mjs": 991, - "config/scripts/mobile-web-app-route-manifest.test.mjs": 220, - "config/scripts/mobile-web-app-route-snapshot.test.mjs": 95, - "config/scripts/mobile-web-app-safe-area-render.test.mjs": 503, - "config/scripts/mobile-web-app-screencast-lane-grant.test.mjs": 56, - "config/scripts/mobile-web-app-session-dictation-capture.test.mjs": 98, - "config/scripts/mobile-web-app-session-external-links.test.mjs": 210, - "config/scripts/mobile-web-app-session-media-picker.test.mjs": 375, - "config/scripts/mobile-web-app-session-render.test.mjs": 495, - "config/scripts/mobile-web-app-session-terminal-closure.test.mjs": 444, - "config/scripts/mobile-web-app-session-terminal-init.test.mjs": 529, - "config/scripts/mobile-web-app-session-text-inputs.test.mjs": 391, - "config/scripts/mobile-web-app-session-webview-consumers.test.mjs": 76, - "config/scripts/mobile-web-app-source-control-external-links.test.mjs": 317, - "config/scripts/mobile-web-app-source-control-keyboard.test.mjs": 59, - "config/scripts/mobile-web-app-source-control-render.test.mjs": 432, - "config/scripts/mobile-web-app-source-control-text-inputs.test.mjs": 295, - "config/scripts/mobile-web-app-tasks-external-links.test.mjs": 349, - "config/scripts/mobile-web-app-tasks-render.test.mjs": 356, - "config/scripts/mobile-web-app-terminal-live-input-render.test.mjs": 431, - "config/scripts/mobile-web-app-terminal-render-fixture.test.mjs": 548, - "config/scripts/mobile-web-app-terminal-render.test.mjs": 346, - "config/scripts/mobile-web-app-web-overrides.test.mjs": 69, - "config/scripts/mobile-web-bundle-manifest.test.mjs": 54, - "config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs": 1102, - "config/scripts/mobile-web-bundle-serializer-parity.test.mjs": 138, - "config/scripts/mobile-web-page-route-hop-coverage.test.mjs": 4953, - "config/scripts/mobile-web-page-routes.test.mjs": 162, - "config/scripts/mobile-web-source-line-endings.test.mjs": 661, - "config/scripts/mobile-web-terminal-engine-closure.test.mjs": 79, - "config/scripts/node-old-space-limit.test.mjs": 32, - "config/scripts/node-pty-console-list-agent-patch.test.mjs": 46, - "config/scripts/node-pty-master-cloexec-patch.test.mjs": 114, - "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 98, - "config/scripts/node-server-change-scope.test.mjs": 3355, - "config/scripts/nsis-process-check.test.mjs": 62, - "config/scripts/orca-cli-skill-guidance.test.mjs": 62, - "config/scripts/orca-dev-bin.test.mjs": 79, - "config/scripts/orca-linear-skill-guidance.test.mjs": 50, - "config/scripts/orcad-artifact-version.test.mjs": 470, - "config/scripts/orcad-operations-restart-safety.test.mjs": 45, - "config/scripts/orcad-terminal-smoke-change-scope.test.mjs": 1510, - "config/scripts/orcad-watcher-package.test.mjs": 132, - "config/scripts/orcad-windows-process-tree.test.mjs": 63, - "config/scripts/orchestration-guide-command-contract.test.mjs": 44, - "config/scripts/orchestration-skill-guidance.test.mjs": 58, - "config/scripts/oxc-cli-invocation.test.mjs": 85, - "config/scripts/oxlint-cli-invocation.test.mjs": 106, - "config/scripts/package-electron-install-owner.test.mjs": 91, - "config/scripts/package-electron-runtime-contract.test.mjs": 447, - "config/scripts/package-linux-formats.test.mjs": 208, - "config/scripts/packaged-browser-lane-contract.test.mjs": 104, - "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 93, - "config/scripts/packaged-source-map-prune.test.mjs": 117, - "config/scripts/patched-dependencies-frozen-install.test.mjs": 900, - "config/scripts/pdfjs-viewer-assets-stream.test.ts": 246, - "config/scripts/plain-node-entry-guard.test.ts": 728, - "config/scripts/playwright-focused-workflow-selection.test.mjs": 3690, - "config/scripts/pnpm-cli-invocation.test.mjs": 38, - "config/scripts/pr-code-change-scope.test.mjs": 638, - "config/scripts/pr-e2e-gate-contract.test.mjs": 409, - "config/scripts/pr-e2e-native-only-routing.test.mjs": 169, - "config/scripts/pr-e2e-pane-close-routing.test.mjs": 55, - "config/scripts/pr-ready-check-gate.test.mjs": 303, - "config/scripts/pr-ready-check-reuse.test.mjs": 190, - "config/scripts/pr-test-loc-summary.test.mjs": 178, - "config/scripts/pr-workflow-lint-parity.test.mjs": 81, - "config/scripts/pr-workflow-parallelism.test.mjs": 1324, - "config/scripts/profile-state-worker-smoke.test.mjs": 2754, - "config/scripts/project-renderer-web-client.test.mjs": 251, - "config/scripts/pty-transcript-secret-scan.test.mjs": 68, - "config/scripts/publish-complete-draft-releases.test.mjs": 230, - "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 2182, - "config/scripts/rebuild-native-deps-node-pty.test.mjs": 4426, - "config/scripts/rebuild-native-deps-windows-process-tree.test.mjs": 40, - "config/scripts/rebuild-native-deps.test.mjs": 1467, - "config/scripts/reclaim-dev-electron-bundles.test.ts": 57, - "config/scripts/regenerate-xterm-patches.test.mjs": 432, - "config/scripts/relay-artifact-manifest.test.mjs": 4048, - "config/scripts/relay-asset-line-ending-pin.test.mjs": 110, - "config/scripts/release-blocker-fixes.test.mjs": 80, - "config/scripts/release-cut-signpath-slack.test.mjs": 95, - "config/scripts/release-cut-sourcemap-publish.test.mjs": 175, - "config/scripts/release-cut-token-permissions.test.mjs": 210, - "config/scripts/release-e2e-dispatch-contract.test.mjs": 184, - "config/scripts/release-mac-build-workflow-dispatch.test.mjs": 45, - "config/scripts/release-rc-history.test.mjs": 244, - "config/scripts/renderer-boot-graph.test.mjs": 98, - "config/scripts/renderer-scrollbar-style-plugin.test.mjs": 2119, - "config/scripts/replace-cached-nsis-elevate.test.mjs": 1919, - "config/scripts/resolve-7za-path.test.mjs": 1815, - "config/scripts/run-codex-real-account-validation.test.ts": 4165, - "config/scripts/run-internal-dev-setup.test.mjs": 54, - "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 71, - "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 73, - "config/scripts/run-typecheck-projects-in-parallel.test.mjs": 47, - "config/scripts/script-entry-detection.test.mjs": 72, - "config/scripts/script-module-dependencies.test.mjs": 57, - "config/scripts/shared-electron-dist-cache.test.ts": 90, - "config/scripts/shebang-script-line-ending-pin.test.mjs": 119, - "config/scripts/skill-critical-guidance.test.mjs": 37, - "config/scripts/skill-description-length.test.mjs": 83, - "config/scripts/skill-recipe-shell.test.mjs": 76, - "config/scripts/skill-sharing-release-workflow.test.mjs": 130, - "config/scripts/skill-update-roundtrip-workflow.test.mjs": 86, - "config/scripts/skills-cli-package-workflow.test.mjs": 137, - "config/scripts/sort-comparator-performance-plugin.test.mjs": 332, - "config/scripts/space-sharing-copy.test.ts": 110, - "config/scripts/ssh-browser-e2e-routing.test.mjs": 138, - "config/scripts/ssh-docker-ci-sharding.test.mjs": 7004, - "config/scripts/ssh-localhost-e2e-routing.test.mjs": 163, + "config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs": 461, + "config/scripts/generate-bundled-skill-guides.test.mjs": 367, + "config/scripts/generate-monaco-associations.test.mjs": 344, + "config/scripts/generate-runtime-required-english-catalog.test.mjs": 432, + "config/scripts/generate-skill-bundle-manifest.test.mjs": 537, + "config/scripts/generate-terminal-perf-html-report.test.mjs": 107, + "config/scripts/generated-artifact-line-ending-pin.test.mjs": 54, + "config/scripts/git-pull-request-diff-base.test.mjs": 52, + "config/scripts/github-opened-issue-repository.test.ts": 1032, + "config/scripts/github-pr-assignee-repository.test.ts": 560, + "config/scripts/hang-watchdog-process-metrics.test.mjs": 102, + "config/scripts/happy-dom-mutation-observer-retention.test.ts": 206, + "config/scripts/happy-dom-offscreen-canvas.test.ts": 195, + "config/scripts/headless-detector-compiler-cache.test.mjs": 1063, + "config/scripts/headless-serve-shutdown-matrix.test.mjs": 84, + "config/scripts/hourly-build-version.test.mjs": 82, + "config/scripts/hourly-preflight-workflow.test.mjs": 182, + "config/scripts/idle-cpu-process-sampling.test.mjs": 64, + "config/scripts/install-electron-package-binary.test.mjs": 2694, + "config/scripts/install-node-dependencies-action.test.mjs": 336, + "config/scripts/lag-probe-failures.test.mjs": 60, + "config/scripts/latest-stable-release.test.mjs": 53, + "config/scripts/linear-sdk-runtime.test.mjs": 985, + "config/scripts/lint-staged-worktree-backup.test.mjs": 253, + "config/scripts/linux-package-fixture-cache.test.mjs": 204, + "config/scripts/live-freeze-bounded-history.test.mjs": 58, + "config/scripts/live-remote-bulk-open-freeze-metrics.test.mjs": 86, + "config/scripts/live-remote-freeze-rpc.test.mjs": 64, + "config/scripts/live-remote-status-watchdog.test.mjs": 284, + "config/scripts/locale-brand-prefilter.test.mjs": 215, + "config/scripts/locale-count-fragment-separator.test.mjs": 99, + "config/scripts/locale-generic-ui-terms.test.mjs": 103, + "config/scripts/locale-ko-frozen-terminal-search-keywords.test.mjs": 126, + "config/scripts/locale-ko-key-overrides.test.mjs": 68, + "config/scripts/locale-repair-catalog-missing-leaves.test.mjs": 103, + "config/scripts/locale-translation-policy-ko-round5.test.mjs": 118, + "config/scripts/locale-translation-policy.es-pr.test.mjs": 97, + "config/scripts/locale-translation-policy.es-round5.test.mjs": 78, + "config/scripts/locale-translation-policy.ja-relocalization.test.mjs": 119, + "config/scripts/locale-translation-policy.ja-round5.test.mjs": 112, + "config/scripts/locale-translation-policy.test.mjs": 119, + "config/scripts/locale-translation-policy.zh-round5.test.mjs": 191, + "config/scripts/locale-translation-policy.zh-status-bar-usage.test.mjs": 105, + "config/scripts/localization-extraction-call-syntax.test.mjs": 292, + "config/scripts/localization-extraction-change-scope.test.mjs": 308, + "config/scripts/mac-build-compatibility.test.mjs": 47, + "config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs": 64, + "config/scripts/macos-computer-helper-owner-loss-processes.test.mjs": 65, + "config/scripts/macos-tcc-prompt-localization.test.mjs": 287, + "config/scripts/managed-data-account-runtime.test.ts": 1001, + "config/scripts/merge-orcad-prebuilds.test.mjs": 82, + "config/scripts/mobile-entry-static-closure-work-budget.test.mjs": 127, + "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 374, + "config/scripts/mobile-process-host-tooling.test.mjs": 978, + "config/scripts/mobile-release-check-scope.test.mjs": 701, + "config/scripts/mobile-test-change-scope.test.mjs": 126, + "config/scripts/mobile-web-app-agent-history-render.test.mjs": 363, + "config/scripts/mobile-web-app-browser-pane-render.test.mjs": 365, + "config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs": 374, + "config/scripts/mobile-web-app-clipboard-image-resize-render.test.mjs": 368, + "config/scripts/mobile-web-app-drawer-render.test.mjs": 365, + "config/scripts/mobile-web-app-editable-host-font-size.test.mjs": 291, + "config/scripts/mobile-web-app-external-link-seam.test.mjs": 247, + "config/scripts/mobile-web-app-external-navigation-grant.test.mjs": 68, + "config/scripts/mobile-web-app-files-external-links.test.mjs": 228, + "config/scripts/mobile-web-app-files-render.test.mjs": 356, + "config/scripts/mobile-web-app-frame-budget-sweep.test.ts": 322, + "config/scripts/mobile-web-app-handoff-grants-render.test.mjs": 395, + "config/scripts/mobile-web-app-haptics-seam.test.mjs": 295, + "config/scripts/mobile-web-app-held-press-render.test.mjs": 367, + "config/scripts/mobile-web-app-html-preview-render.test.mjs": 517, + "config/scripts/mobile-web-app-media-preview-render.test.mjs": 360, + "config/scripts/mobile-web-app-mermaid-render.test.mjs": 354, + "config/scripts/mobile-web-app-native-chat-visual-links.test.mjs": 365, + "config/scripts/mobile-web-app-native-parity-render.test.mjs": 399, + "config/scripts/mobile-web-app-page-closure-families.test.mjs": 74, + "config/scripts/mobile-web-app-page-grant-call-sites.test.mjs": 302, + "config/scripts/mobile-web-app-push-subsystem-closure.test.mjs": 64, + "config/scripts/mobile-web-app-render.test.mjs": 403, + "config/scripts/mobile-web-app-rich-markdown-render.test.mjs": 721, + "config/scripts/mobile-web-app-route-manifest.test.mjs": 164, + "config/scripts/mobile-web-app-route-snapshot.test.mjs": 121, + "config/scripts/mobile-web-app-safe-area-render.test.mjs": 365, + "config/scripts/mobile-web-app-screencast-lane-grant.test.mjs": 69, + "config/scripts/mobile-web-app-session-dictation-capture.test.mjs": 248, + "config/scripts/mobile-web-app-session-external-links.test.mjs": 197, + "config/scripts/mobile-web-app-session-media-picker.test.mjs": 270, + "config/scripts/mobile-web-app-session-render.test.mjs": 372, + "config/scripts/mobile-web-app-session-terminal-closure.test.mjs": 231, + "config/scripts/mobile-web-app-session-terminal-init.test.mjs": 352, + "config/scripts/mobile-web-app-session-text-inputs.test.mjs": 237, + "config/scripts/mobile-web-app-session-webview-consumers.test.mjs": 67, + "config/scripts/mobile-web-app-source-control-external-links.test.mjs": 215, + "config/scripts/mobile-web-app-source-control-keyboard.test.mjs": 56, + "config/scripts/mobile-web-app-source-control-render.test.mjs": 325, + "config/scripts/mobile-web-app-source-control-text-inputs.test.mjs": 334, + "config/scripts/mobile-web-app-stack-transition-render.test.mjs": 357, + "config/scripts/mobile-web-app-tasks-external-links.test.mjs": 251, + "config/scripts/mobile-web-app-tasks-render.test.mjs": 369, + "config/scripts/mobile-web-app-terminal-live-input-render.test.mjs": 332, + "config/scripts/mobile-web-app-terminal-render-fixture.test.mjs": 380, + "config/scripts/mobile-web-app-terminal-render.test.mjs": 352, + "config/scripts/mobile-web-app-working-spinner-render.test.mjs": 382, + "config/scripts/mobile-web-bundle-manifest.test.mjs": 224, + "config/scripts/mobile-web-bundle-serializer-parity.test.mjs": 237, + "config/scripts/mobile-web-page-routes.test.mjs": 250, + "config/scripts/mobile-web-source-line-endings.test.mjs": 261, + "config/scripts/mobile-web-terminal-engine-closure.test.mjs": 60, + "config/scripts/moved-code-normalization-budget.test.mjs": 125, + "config/scripts/msbuild-file-tracking.test.mjs": 44, + "config/scripts/native-chat-compaction-pagination.test.mjs": 446, + "config/scripts/node-old-space-limit.test.mjs": 50, + "config/scripts/node-server-change-scope.test.mjs": 2687, + "config/scripts/node-server-concurrency.test.mjs": 173, + "config/scripts/node-server-qualification.test.mjs": 62, + "config/scripts/nsis-process-check.test.mjs": 59, + "config/scripts/orca-dev-bin.test.mjs": 118, + "config/scripts/orcad-artifact-version.test.mjs": 493, + "config/scripts/orcad-daemon-protocol-crossing.test.mjs": 57, + "config/scripts/orcad-e2e-routing.test.mjs": 184, + "config/scripts/orcad-prebuild-slot-contents.test.mjs": 81, + "config/scripts/orcad-terminal-smoke-change-scope.test.mjs": 2562, + "config/scripts/orcad-watcher-package.test.mjs": 107, + "config/scripts/orcad-windows-prebuild-cache.test.mjs": 446, + "config/scripts/orcad-windows-prebuild-workflow.test.mjs": 371, + "config/scripts/orcad-windows-process-tree.test.mjs": 94, + "config/scripts/oxc-cli-invocation.test.mjs": 102, + "config/scripts/oxlint-cli-invocation.test.mjs": 73, + "config/scripts/package-electron-runtime-contract.test.mjs": 169, + "config/scripts/package-linux-formats-appimage.test.mjs": 577, + "config/scripts/package-linux-formats.test.mjs": 315, + "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 68, + "config/scripts/packaged-orcad-template.test.mjs": 185, + "config/scripts/packaged-process-host.test.mjs": 653, + "config/scripts/packaged-source-map-prune.test.mjs": 69, + "config/scripts/patched-dependencies-frozen-install.test.mjs": 574, + "config/scripts/pdfjs-viewer-assets-stream.test.ts": 317, + "config/scripts/plain-node-entry-guard.test.ts": 680, + "config/scripts/playwright-focused-workflow-selection.test.mjs": 3051, + "config/scripts/pnpm-cli-invocation.test.mjs": 63, + "config/scripts/pr-auxiliary-preflight-admission.test.mjs": 417, + "config/scripts/pr-code-change-scope-cross-version-launch-route.test.mjs": 49, + "config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs": 55, + "config/scripts/pr-code-change-scope-daemon-protocol.test.mjs": 44, + "config/scripts/pr-code-change-scope-process-host.test.mjs": 54, + "config/scripts/pr-code-change-scope.test.mjs": 551, + "config/scripts/pr-codex-unit-only-selection.test.mjs": 45, + "config/scripts/pr-e2e-gate-contract.test.mjs": 264, + "config/scripts/pr-e2e-native-only-routing.test.mjs": 153, + "config/scripts/pr-e2e-pane-close-routing.test.mjs": 61, + "config/scripts/pr-preflight-gates.test.mjs": 203, + "config/scripts/pr-ready-check-gate.test.mjs": 338, + "config/scripts/pr-ready-check-reuse.test.mjs": 171, + "config/scripts/pr-test-loc-summary.test.mjs": 179, + "config/scripts/pr-unit-support-routing.test.mjs": 64, + "config/scripts/pr-workflow-parallelism.test.mjs": 151, + "config/scripts/process-failure-message.test.mjs": 44, + "config/scripts/process-host-build-dist.test.mjs": 32253, + "config/scripts/process-host-dev-rebuild.test.ts": 9293, + "config/scripts/profile-state-worker-smoke.test.mjs": 2753, + "config/scripts/project-renderer-web-client.test.mjs": 623, + "config/scripts/pty-transcript-secret-scan.test.mjs": 168, + "config/scripts/publish-complete-draft-releases.test.mjs": 151, + "config/scripts/pullfrog-review-scope.test.mjs": 69, + "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 1652, + "config/scripts/readme-downloads-badge.test.mjs": 37, + "config/scripts/rebuild-native-deps-node-pty.test.mjs": 1261, + "config/scripts/rebuild-native-deps-windows-process-tree.test.mjs": 45, + "config/scripts/rebuild-native-deps.test.mjs": 1263, + "config/scripts/reclaim-dev-electron-bundles.test.ts": 64, + "config/scripts/regenerate-xterm-patches.test.mjs": 249, + "config/scripts/relay-windows-process-tree-prepared-addon.test.mjs": 168, + "config/scripts/release-cut-token-permissions.test.mjs": 199, + "config/scripts/release-javascript-artifact.test.mjs": 253, + "config/scripts/release-javascript-parity.test.mjs": 98, + "config/scripts/release-mac-build-workflow-dispatch.test.mjs": 56, + "config/scripts/release-policy.test.mjs": 62, + "config/scripts/release-rc-history.test.mjs": 147, + "config/scripts/release-tag-pattern-census.test.mjs": 97, + "config/scripts/renderer-boot-graph.test.mjs": 88, + "config/scripts/renderer-scrollbar-style-plugin.test.mjs": 2564, + "config/scripts/replace-cached-nsis-elevate.test.mjs": 1852, + "config/scripts/resolve-7za-path.test.mjs": 1333, + "config/scripts/run-codex-real-account-validation.test.ts": 3394, + "config/scripts/run-internal-dev-setup.test.mjs": 59, + "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 58, + "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 77, + "config/scripts/run-typecheck-projects-in-parallel.test.mjs": 59, + "config/scripts/scan-release-artifacts-antivirus.test.mjs": 68, + "config/scripts/script-entry-detection.test.mjs": 91, + "config/scripts/script-module-dependencies.test.mjs": 63, + "config/scripts/session-scanner-service-smoke.test.mjs": 549, + "config/scripts/shared-electron-dist-cache.test.ts": 105, + "config/scripts/shebang-script-line-ending-pin.test.mjs": 103, + "config/scripts/skill-description-length.test.mjs": 107, + "config/scripts/skill-recipe-shell.test.mjs": 103, + "config/scripts/sort-comparator-performance-plugin.test.mjs": 283, + "config/scripts/space-sharing-copy.test.ts": 96, + "config/scripts/ssh-browser-e2e-routing.test.mjs": 176, + "config/scripts/stable-release-tag-selection-budget.test.mjs": 149, "config/scripts/static-appimage-package-contract.test.mjs": 83, - "config/scripts/summarize-terminal-perf-report.test.mjs": 74, - "config/scripts/telemetry-bundle-constant-patterns.test.mjs": 45, - "config/scripts/terminal-ime-e2e-workflow.test.mjs": 79, - "config/scripts/terminal-ime-engagement-receipt.test.mjs": 37, - "config/scripts/terminal-perf-report-annotations.test.mjs": 60, - "config/scripts/trim-windows-icon-source.test.mjs": 289, - "config/scripts/verify-cli-bin.test.mjs": 102, - "config/scripts/verify-dev-channel-packaging.test.mjs": 655, - "config/scripts/verify-linux-glibc-floor.test.mjs": 142, - "config/scripts/verify-linux-glibc-payloads.test.mjs": 223, - "config/scripts/verify-localization-catalog.test.mjs": 671, - "config/scripts/verify-localization-extraction.test.mjs": 36, - "config/scripts/verify-packaged-browser-participation.test.mjs": 43, - "config/scripts/verify-packaged-daemon-entry.test.mjs": 135, - "config/scripts/verify-packaged-mobile-web-bundle.test.mjs": 439, - "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 61, - "config/scripts/verify-packaged-orcad-template.test.mjs": 466, - "config/scripts/verify-packaged-plugin-resources.test.mjs": 68, - "config/scripts/verify-release-required-assets.test.mjs": 33, - "config/scripts/verify-skills-cli-runtime.test.mjs": 381, - "config/scripts/verify-windows-inner-signature.test.mjs": 46, - "config/scripts/verify-wsl-e2e-participation.test.mjs": 58, - "config/scripts/websocket-server-loopback-bind.test.ts": 898, - "config/scripts/win-crash-survival-e2e.test.mjs": 775, - "config/scripts/win32-test-lane-registration.test.mjs": 2421, - "config/scripts/windows-bin-spawn-predicate.test.mjs": 363, - "config/scripts/windows-cmd-shim-spawn-boundary.test.mjs": 2079, - "config/scripts/windows-pe-machine.test.mjs": 41, - "config/scripts/windows-process-tree-creation-time.test.mjs": 43, - "config/scripts/windows-process-tree-gyp-path.test.mjs": 47, - "config/scripts/windows-process-tree-gyp-rebuild.test.mjs": 104, - "config/scripts/windows-process-tree-patch-contract.test.mjs": 711, - "config/scripts/windows-pty-native-capability-workflow.test.mjs": 142, - "config/scripts/windows-signing-gate-toolset.test.mjs": 148, - "config/scripts/windows-signing-workflow-contract.test.mjs": 589, - "config/scripts/windows-uninstaller-signing.test.mjs": 65, - "config/scripts/workflow-ref-mirror-case-safety.test.mjs": 435, - "config/scripts/workflow-ref-reachability.test.mjs": 783, - "config/scripts/wsl-e2e-lane-contract.test.mjs": 208, - "config/scripts/xterm-image-lifecycle-contract.test.mjs": 458, - "config/scripts/xterm-image-memory-contract.test.mjs": 240, - "config/scripts/xterm-image-resize-contract.test.mjs": 69, - "config/scripts/xterm-webgl-runtime-contract.test.mjs": 51, - "config/scripts/zip-extractor-command.test.mjs": 83, - "src/cli/agent-context.test.ts": 56, - "src/cli/agent-session-search-format.test.ts": 56, - "src/cli/args-repeatable-flags.test.ts": 51, - "src/cli/args.test.ts": 259, - "src/cli/automation-format.test.ts": 48, - "src/cli/automation-owner-conflict-recovery.test.ts": 236, - "src/cli/base64-payload-byte-count.test.ts": 28, - "src/cli/browser-cookie-credentials-empty-value.test.ts": 190, - "src/cli/browser-storage-empty-value.test.ts": 97, - "src/cli/browser.test.ts": 279, - "src/cli/cli-command-name-parity.test.ts": 80, - "src/cli/cli-version.test.ts": 60, - "src/cli/codex-command-classification.test.ts": 44, - "src/cli/command-suggestion-budget.test.ts": 77, - "src/cli/command-suggestion.test.ts": 103, - "src/cli/computer-format.test.ts": 35, - "src/cli/emulator-logcat-format.test.ts": 41, - "src/cli/execution-host-flag.test.ts": 83, - "src/cli/flags.test.ts": 34, - "src/cli/format-recovery.test.ts": 275, - "src/cli/format.test.ts": 481, - "src/cli/handler-group-manifest.test.ts": 813, - "src/cli/handlers/account.test.ts": 957, - "src/cli/handlers/agent-hooks-load-boundary.test.ts": 282, - "src/cli/handlers/agent-hooks.test.ts": 998, - "src/cli/handlers/artifacts.test.ts": 201, - "src/cli/handlers/automation-destination-fencing.test.ts": 171, - "src/cli/handlers/automation-owner-fencing.test.ts": 306, - "src/cli/handlers/computer-action-routing.test.ts": 137, - "src/cli/handlers/computer-action-validation.test.ts": 204, - "src/cli/handlers/computer-state-formatting.test.ts": 94, - "src/cli/handlers/computer.test.ts": 204, - "src/cli/handlers/core.test.ts": 182, - "src/cli/handlers/emulator.test.ts": 192, - "src/cli/handlers/file-absolute-paths.test.ts": 94, - "src/cli/handlers/file.test.ts": 398, - "src/cli/handlers/interactive-login-interruption.test.ts": 37, - "src/cli/handlers/linear.test.ts": 207, - "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 283, - "src/cli/handlers/orchestration-check-identity.test.ts": 294, - "src/cli/handlers/orchestration-federated-legacy-settlement.test.ts": 251, - "src/cli/handlers/orchestration-gate-cli.test.ts": 385, - "src/cli/handlers/orchestration-legacy-read-only.test.ts": 238, - "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 261, - "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 297, - "src/cli/handlers/orchestration-migration.test.ts": 312, - "src/cli/handlers/orchestration-module-boundaries.test.ts": 358, - "src/cli/handlers/orchestration-request-show-cli.test.ts": 293, - "src/cli/handlers/orchestration-run-cli.test.ts": 200, - "src/cli/handlers/orchestration-send-receipt-warnings.test.ts": 284, - "src/cli/handlers/orchestration-task-create-cli.test.ts": 201, - "src/cli/handlers/orchestration-task-list-brief.test.ts": 155, - "src/cli/handlers/orchestration-timeout-cli.test.ts": 361, - "src/cli/handlers/orchestration-timeout.test.ts": 280, - "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 160, - "src/cli/handlers/orchestration-worker-cli.test.ts": 527, - "src/cli/handlers/orchestration-worker-show-wait-cli.test.ts": 214, - "src/cli/handlers/orchestration.test.ts": 549, - "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 243, - "src/cli/handlers/orchestration/worker-output.test.ts": 56, - "src/cli/handlers/profile-state-recovery-admission.test.ts": 1544, - "src/cli/handlers/profile-state.test.ts": 783, - "src/cli/handlers/search.test.ts": 251, - "src/cli/handlers/skill-sharing.test.ts": 232, - "src/cli/handlers/terminal.test.ts": 318, - "src/cli/host-selector-alternatives.test.ts": 46, - "src/cli/index-automation-identifiers.test.ts": 189, - "src/cli/index-automation-schedule.test.ts": 151, - "src/cli/index-automation-session-reuse.test.ts": 162, - "src/cli/index-automation-source-context.test.ts": 103, - "src/cli/index-automation-target.test.ts": 197, - "src/cli/index-device-commands.test.ts": 170, - "src/cli/index-environment-commands.test.ts": 139, - "src/cli/index-local-command-routing-flags.test.ts": 430, - "src/cli/index-memory-diagnostics.test.ts": 160, - "src/cli/index-omitted-host-scope-selectors.test.ts": 230, - "src/cli/index-orchestration.test.ts": 201, - "src/cli/index-project-setup.test.ts": 143, - "src/cli/index-serve-command.test.ts": 193, - "src/cli/index-terminal-commands.test.ts": 344, - "src/cli/index-terminal-list-host-scope.test.ts": 161, - "src/cli/index-vm-recipe-doctor.test.ts": 408, - "src/cli/index-worktree-create-agent.test.ts": 143, - "src/cli/index-worktree-create-linear.test.ts": 94, - "src/cli/index-worktree-create-parent.test.ts": 386, - "src/cli/index-worktree-create-target.test.ts": 98, - "src/cli/index-worktree-selector-resolution.test.ts": 352, - "src/cli/index-worktree-set.test.ts": 184, - "src/cli/index.test.ts": 981, - "src/cli/linear-format.test.ts": 47, - "src/cli/orchestration-dispatch-refusal-format.test.ts": 75, - "src/cli/orchestration-mutation-recovery.test.ts": 212, - "src/cli/orchestration-structured-sender-identity.test.ts": 137, - "src/cli/orchestration-structured-session-no-identity.test.ts": 389, - "src/cli/quote-stripped-json-flag.test.ts": 29, - "src/cli/registry-parity.test.ts": 78, - "src/cli/retry-request-flag.test.ts": 367, - "src/cli/runtime-client-deferral.test.ts": 715, - "src/cli/runtime-client.test.ts": 424, - "src/cli/runtime/client-recovery.test.ts": 463, - "src/cli/runtime/client-timeout-policy.test.ts": 250, - "src/cli/runtime/envelope-schema.test.ts": 87, - "src/cli/runtime/environments.test.ts": 217, - "src/cli/runtime/launch.test.ts": 181, - "src/cli/runtime/orchestration-recovery-command.test.ts": 60, - "src/cli/runtime/profile-state-recovery-launch.test.ts": 185, - "src/cli/runtime/runtime-access-denied.test.ts": 122, - "src/cli/runtime/serve-signal-exit-diagnostic.test.ts": 66, - "src/cli/runtime/status.test.ts": 233, - "src/cli/runtime/transport-framing.test.ts": 222, - "src/cli/runtime/transport.test.ts": 656, - "src/cli/runtime/types.test.ts": 38, - "src/cli/runtime/websocket-transport-error.test.ts": 243, - "src/cli/runtime/websocket-transport.test.ts": 355, - "src/cli/search-command-arguments.test.ts": 275, - "src/cli/serve-electron-flag-parity.test.ts": 58, - "src/cli/shell-command-quote.test.ts": 38, - "src/cli/skill-guide-cli-parity.test.ts": 106, - "src/cli/skills-reference-selector.test.ts": 194, - "src/cli/skills.test.ts": 1332, - "src/cli/specs/account.test.ts": 50, - "src/cli/specs/bundled-guide-flags.test.ts": 262, - "src/cli/specs/computer.test.ts": 44, - "src/cli/specs/orchestration.test.ts": 33, - "src/cli/specs/profile-state.test.ts": 43, - "src/cli/specs/search.test.ts": 121, - "src/cli/specs/skills.test.ts": 50, - "src/cli/terminal-format-draft.test.ts": 50, - "src/cli/terminal-format.test.ts": 44, - "src/cli/terminal-list-host-scope-format.test.ts": 71, - "src/cli/terminal-read-screen.test.ts": 126, - "src/cli/vocabulary-policy.test.ts": 67, - "src/cli/worktree-selector-wsl-posix-path.test.ts": 49, - "src/main/active-view-persistence-boundary.test.ts": 783, - "src/main/active-view-preference-sync-flush-veto.test.ts": 788, - "src/main/active-view-preference.test.ts": 64, - "src/main/agent-auth-restart-preservation.test.ts": 71, - "src/main/agent-awake-service-platform-assertions.test.ts": 99, - "src/main/agent-awake-service.test.ts": 106, - "src/main/agent-hooks/branch-rename-failure-output.test.ts": 26, - "src/main/agent-hooks/ended-process-reconciliation.test.ts": 510, - "src/main/agent-hooks/first-work-branch-rename.test.ts": 365, - "src/main/agent-hooks/first-work-folder-rename.test.ts": 87, - "src/main/agent-hooks/grok-replay-guard.test.ts": 101, - "src/main/agent-hooks/hook-script-outside-orca.test.ts": 210, - "src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 578, - "src/main/agent-hooks/install-telemetry.test.ts": 58, - "src/main/agent-hooks/installer-utils-remote.test.ts": 66, - "src/main/agent-hooks/installer-utils.test.ts": 180, - "src/main/agent-hooks/local-agent-cli-presence.test.ts": 76, - "src/main/agent-hooks/managed-agent-hook-controls.test.ts": 261, - "src/main/agent-hooks/managed-hook-command-contract.test.ts": 309, - "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 64, - "src/main/agent-hooks/managed-hook-install-lock.test.ts": 388, - "src/main/agent-hooks/managed-hook-local-filesystem.test.ts": 327, - "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 84, - "src/main/agent-hooks/managed-hook-runtime.test.ts": 409, - "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 330, - "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 471, - "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 2177, - "src/main/agent-hooks/managed-hook-timeout.test.ts": 1803, - "src/main/agent-hooks/managed-toml-ownership.test.ts": 50, - "src/main/agent-hooks/manual-compact-hook-stream.test.ts": 582, - "src/main/agent-hooks/manual-compact-status-cleanup.test.ts": 553, - "src/main/agent-hooks/migration-unsupported-pty-state.test.ts": 31, - "src/main/agent-hooks/opencode-message-part-flood-bench.test.ts": 1229, - "src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts": 3494, - "src/main/agent-hooks/remote-hook-service-installers.test.ts": 367, - "src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts": 238, - "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 930, - "src/main/agent-hooks/server-ai-vault-liveness.test.ts": 606, - "src/main/agent-hooks/server-amp-normalization.test.ts": 570, - "src/main/agent-hooks/server-authority-evidence.test.ts": 639, - "src/main/agent-hooks/server-claude-cancel-captures.test.ts": 856, - "src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts": 508, - "src/main/agent-hooks/server-claude-normalization.test.ts": 497, - "src/main/agent-hooks/server-claude-permission-visibility.test.ts": 836, - "src/main/agent-hooks/server-claude-statusline.test.ts": 592, - "src/main/agent-hooks/server-closed-tab-suppression.test.ts": 614, - "src/main/agent-hooks/server-codex-normalization.test.ts": 575, - "src/main/agent-hooks/server-codex-subagent-transcript.test.ts": 4597, - "src/main/agent-hooks/server-copilot-normalization.test.ts": 1055, - "src/main/agent-hooks/server-cursor-normalization.test.ts": 550, - "src/main/agent-hooks/server-droid-normalization.test.ts": 592, - "src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts": 568, - "src/main/agent-hooks/server-escape-navigation-inference.test.ts": 739, - "src/main/agent-hooks/server-gemini-normalization.test.ts": 565, - "src/main/agent-hooks/server-grok-background-status.test.ts": 760, - "src/main/agent-hooks/server-grok-cancel.test.ts": 610, - "src/main/agent-hooks/server-grok-discovery.test.ts": 1077, - "src/main/agent-hooks/server-hook-http-ingest.test.ts": 388, - "src/main/agent-hooks/server-ingest-remote.test.ts": 449, - "src/main/agent-hooks/server-ingest-structured-children.test.ts": 590, - "src/main/agent-hooks/server-ingest-structured-state-clock.test.ts": 630, - "src/main/agent-hooks/server-ingest-structured-status.test.ts": 585, - "src/main/agent-hooks/server-ingest-terminal-status.test.ts": 382, - "src/main/agent-hooks/server-interrupt-inference-guards.test.ts": 693, - "src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts": 646, - "src/main/agent-hooks/server-interrupt-inference-validation.test.ts": 419, - "src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts": 713, - "src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts": 389, - "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts": 488, - "src/main/agent-hooks/server-last-status-lead-boundary.test.ts": 647, - "src/main/agent-hooks/server-last-status-main-agent-fact.test.ts": 584, - "src/main/agent-hooks/server-last-status-restored-children.test.ts": 766, - "src/main/agent-hooks/server-last-status-write.test.ts": 699, - "src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts": 702, - "src/main/agent-hooks/server-muse-session-log-poll.test.ts": 2487, - "src/main/agent-hooks/server-observation-provenance.test.ts": 625, - "src/main/agent-hooks/server-omp-retired-ingress.test.ts": 610, - "src/main/agent-hooks/server-omp-retirement-ack.test.ts": 557, - "src/main/agent-hooks/server-opencode-binder.test.ts": 652, - "src/main/agent-hooks/server-opencode-lifecycle.test.ts": 765, - "src/main/agent-hooks/server-opencode-normalization.test.ts": 380, - "src/main/agent-hooks/server-pane-authority.test.ts": 655, - "src/main/agent-hooks/server-pi-normalization.test.ts": 557, - "src/main/agent-hooks/server-prompt-sent-telemetry.test.ts": 643, - "src/main/agent-hooks/server-relay-listener-replay.test.ts": 551, - "src/main/agent-hooks/server-relayed-claude-cancel.test.ts": 678, - "src/main/agent-hooks/server-reminted-pane-key.test.ts": 672, - "src/main/agent-hooks/server-replay-evidence-clock.test.ts": 552, - "src/main/agent-hooks/server-retired-pane-new-turn.test.ts": 383, - "src/main/agent-hooks/server-start-failure-lifecycle.test.ts": 597, - "src/main/agent-hooks/server-status-listener-fanout.test.ts": 739, - "src/main/agent-hooks/server-structured-canonical-status.test.ts": 408, - "src/main/agent-hooks/server-transport-interference.test.ts": 6270, - "src/main/agent-hooks/server.claude-interactive-question.test.ts": 396, - "src/main/agent-hooks/spool.test.ts": 891, - "src/main/agent-hooks/terminal-handle-row-identity.test.ts": 367, - "src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts": 69, - "src/main/agent-hooks/windows-hook-payload-delivery.test.ts": 151, - "src/main/agent-hooks/windows-hook-post-interpreter.test.ts": 330, - "src/main/agent-hooks/windows-powershell-hook-launcher.test.ts": 49, - "src/main/agent-hooks/wsl-guest-plugin-install.test.ts": 68, - "src/main/agent-hooks/wsl-hook-relay-launch.test.ts": 90, - "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts": 4381, - "src/main/agent-hooks/wsl-hook-relay-manager.test.ts": 1974, - "src/main/agent-hooks/wsl-hook-relay-reattach.test.ts": 713, - "src/main/agent-hooks/wsl-hook-relay-recovery.test.ts": 2365, - "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts": 37, - "src/main/agent-launch/agent-launch-executor.test.ts": 330, - "src/main/agent-state-file-reader.test.ts": 97, - "src/main/agent-trust-presets.test.ts": 137, - "src/main/ai-vault-search/session-search-child-service.test.ts": 338, - "src/main/ai-vault-search/session-search-content-hash.test.ts": 71, - "src/main/ai-vault-search/session-search-cwd-key.test.ts": 78, - "src/main/ai-vault-search/session-search-deleted-sources.test.ts": 377, - "src/main/ai-vault-search/session-search-directory-listings.test.ts": 50, - "src/main/ai-vault-search/session-search-engine.test.ts": 3299, - "src/main/ai-vault-search/session-search-file-id.test.ts": 211, - "src/main/ai-vault-search/session-search-file-write.test.ts": 622, - "src/main/ai-vault-search/session-search-fts5-contract.test.ts": 144, - "src/main/ai-vault-search/session-search-hit-ranking.test.ts": 90, - "src/main/ai-vault-search/session-search-host-registration.test.ts": 993, - "src/main/ai-vault-search/session-search-identifier-split.test.ts": 39, - "src/main/ai-vault-search/session-search-index-consumer.test.ts": 255, - "src/main/ai-vault-search/session-search-index-generation.test.ts": 579, - "src/main/ai-vault-search/session-search-index-pass.test.ts": 419, - "src/main/ai-vault-search/session-search-index-writer.test.ts": 480, - "src/main/ai-vault-search/session-search-indexer.test.ts": 4288, - "src/main/ai-vault-search/session-search-instance.test.ts": 869, - "src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts": 3013, - "src/main/ai-vault-search/session-search-live-transcript.test.ts": 289, - "src/main/ai-vault-search/session-search-merged-roots.test.ts": 409, - "src/main/ai-vault-search/session-search-message-rows.test.ts": 420, - "src/main/ai-vault-search/session-search-native-chat-indexing.test.ts": 332, - "src/main/ai-vault-search/session-search-opencode-index.test.ts": 955, - "src/main/ai-vault-search/session-search-orphan-rows.test.ts": 302, - "src/main/ai-vault-search/session-search-paging.test.ts": 540, - "src/main/ai-vault-search/session-search-query-planner.test.ts": 29, - "src/main/ai-vault-search/session-search-read-decision.test.ts": 55, - "src/main/ai-vault-search/session-search-retention-delete.test.ts": 171, - "src/main/ai-vault-search/session-search-retention-policy.test.ts": 76, - "src/main/ai-vault-search/session-search-root-refresh.test.ts": 503, - "src/main/ai-vault-search/session-search-row-filter.test.ts": 190, - "src/main/ai-vault-search/session-search-row-identity.test.ts": 105, - "src/main/ai-vault-search/session-search-scan-roots.test.ts": 195, - "src/main/ai-vault-search/session-search-schema.test.ts": 458, - "src/main/ai-vault-search/session-search-scope-entry-points.test.ts": 3912, - "src/main/ai-vault-search/session-search-scope-resolution.test.ts": 121, - "src/main/ai-vault-search/session-search-scope-service.test.ts": 179, - "src/main/ai-vault-search/session-search-service-registry.test.ts": 217, - "src/main/ai-vault-search/session-search-service.test.ts": 197, - "src/main/ai-vault-search/session-search-sidebar-parity.test.ts": 288, - "src/main/ai-vault-search/session-search-snippet-marks.test.ts": 213, - "src/main/ai-vault-search/session-search-store-is-memory.test.ts": 604, - "src/main/ai-vault-search/session-search-store-scope-catalog.test.ts": 40, - "src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 252, - "src/main/ai-vault-search/session-search-typo-policy.test.ts": 148, - "src/main/ai-vault-search/session-search-typo-scope.test.ts": 162, - "src/main/ai-vault-search/session-search-write-lifetime.test.ts": 246, - "src/main/ai-vault/ai-vault-scan-cancellation.test.ts": 49, - "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 81, - "src/main/ai-vault/cached-session-list-wsl-probe.test.ts": 209, - "src/main/ai-vault/cached-session-list.test.ts": 447, - "src/main/ai-vault/claude-project-dir-encoding.test.ts": 28, - "src/main/ai-vault/codex-session-collection.test.ts": 127, - "src/main/ai-vault/codex-session-root-dedup.test.ts": 116, - "src/main/ai-vault/local-log-tail-reader.test.ts": 35, - "src/main/ai-vault/omp-session-root.test.ts": 159, - "src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts": 917, - "src/main/ai-vault/remote-session-content-lines.test.ts": 74, - "src/main/ai-vault/remote-session-large-transcripts.test.ts": 2163, - "src/main/ai-vault/remote-session-parse-cache.test.ts": 229, - "src/main/ai-vault/remote-session-scan-batching.test.ts": 32, - "src/main/ai-vault/remote-session-scanner-muse.test.ts": 167, - "src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts": 172, - "src/main/ai-vault/remote-session-scanner-opencode.test.ts": 238, - "src/main/ai-vault/remote-session-scanner-sources.test.ts": 118, - "src/main/ai-vault/remote-session-scanner.test.ts": 289, - "src/main/ai-vault/remote-session-sidecar-observation.test.ts": 134, - "src/main/ai-vault/remote-session-stream-lifecycle.test.ts": 74, - "src/main/ai-vault/runtime-session-scanner.test.ts": 87, - "src/main/ai-vault/runtime-session-search-call.test.ts": 57, - "src/main/ai-vault/session-cwd-bucket-layouts.test.ts": 26, - "src/main/ai-vault/session-delete-target.test.ts": 112, - "src/main/ai-vault/session-delete.test.ts": 215, - "src/main/ai-vault/session-document-stream-boundaries.test.ts": 140, - "src/main/ai-vault/session-document-stream-parity.test.ts": 115, - "src/main/ai-vault/session-document-stream-projection.test.ts": 100, - "src/main/ai-vault/session-first-user-prompt-opencode.test.ts": 155, - "src/main/ai-vault/session-first-user-prompt-read.test.ts": 110, - "src/main/ai-vault/session-list-result-validation.test.ts": 155, - "src/main/ai-vault/session-list-results.test.ts": 90, - "src/main/ai-vault/session-newest-files.test.ts": 126, - "src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts": 214, - "src/main/ai-vault/session-parse-cache-persistence.test.ts": 568, - "src/main/ai-vault/session-scan-cutoff.test.ts": 68, - "src/main/ai-vault/session-scanner-agent-root-overrides.test.ts": 1121, - "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 72, - "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 330, - "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 245, - "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 170, - "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 137, - "src/main/ai-vault/session-scanner-claude-title.test.ts": 235, - "src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts": 176, - "src/main/ai-vault/session-scanner-cline-parser.test.ts": 247, - "src/main/ai-vault/session-scanner-codex-dual-root.test.ts": 317, - "src/main/ai-vault/session-scanner-codex-fast-path.test.ts": 404, - "src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts": 41, - "src/main/ai-vault/session-scanner-codex-parser.test.ts": 127, - "src/main/ai-vault/session-scanner-codex-title-index.test.ts": 213, - "src/main/ai-vault/session-scanner-codex-tool-records.test.ts": 75, - "src/main/ai-vault/session-scanner-codex-workers.test.ts": 273, - "src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts": 204, - "src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts": 493, - "src/main/ai-vault/session-scanner-cutoff.test.ts": 340, - "src/main/ai-vault/session-scanner-dedup-batches.test.ts": 320, - "src/main/ai-vault/session-scanner-devin-contention.test.ts": 265, - "src/main/ai-vault/session-scanner-devin-db.test.ts": 385, - "src/main/ai-vault/session-scanner-devin-dedup.test.ts": 305, - "src/main/ai-vault/session-scanner-devin-parser.test.ts": 115, - "src/main/ai-vault/session-scanner-directory-reader.test.ts": 66, - "src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 101, - "src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 60, - "src/main/ai-vault/session-scanner-fs-import-guard.test.ts": 109, - "src/main/ai-vault/session-scanner-graph-parsers.test.ts": 223, - "src/main/ai-vault/session-scanner-grok-parser.test.ts": 111, - "src/main/ai-vault/session-scanner-grok-user-text.test.ts": 47, - "src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts": 73, - "src/main/ai-vault/session-scanner-injected-title.test.ts": 265, - "src/main/ai-vault/session-scanner-jsonl-reader.test.ts": 355, - "src/main/ai-vault/session-scanner-kimi-index-cache.test.ts": 103, - "src/main/ai-vault/session-scanner-kimi-parser.test.ts": 152, - "src/main/ai-vault/session-scanner-muse.test.ts": 250, - "src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts": 94, - "src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts": 234, - "src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts": 91, - "src/main/ai-vault/session-scanner-omp-title.test.ts": 105, - "src/main/ai-vault/session-scanner-opencode-cancellation.test.ts": 393, - "src/main/ai-vault/session-scanner-opencode-parser.test.ts": 112, - "src/main/ai-vault/session-scanner-opencode-process-admission.test.ts": 264, - "src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts": 103, - "src/main/ai-vault/session-scanner-opencode-sources.test.ts": 65, - "src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts": 656, - "src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts": 373, - "src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts": 3466, - "src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts": 35, - "src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts": 3607, - "src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts": 87, - "src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.test.ts": 103, - "src/main/ai-vault/session-scanner-opencode-sqlite.test.ts": 207, - "src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts": 80, - "src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts": 129, - "src/main/ai-vault/session-scanner-opencode2-message.test.ts": 47, - "src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts": 139, - "src/main/ai-vault/session-scanner-parse-cache-agents.test.ts": 663, - "src/main/ai-vault/session-scanner-parse-cache.test.ts": 294, - "src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts": 230, - "src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts": 115, - "src/main/ai-vault/session-scanner-pi-scope.test.ts": 310, - "src/main/ai-vault/session-scanner-preview-window-truncation.test.ts": 55, - "src/main/ai-vault/session-scanner-record-budget.test.ts": 443, - "src/main/ai-vault/session-scanner-recoverable-empty.test.ts": 316, - "src/main/ai-vault/session-scanner-roots.test.ts": 79, - "src/main/ai-vault/session-scanner-scope.test.ts": 200, - "src/main/ai-vault/session-scanner-service-cancellation.test.ts": 203, + "config/scripts/static-module-specifiers.test.mjs": 277, + "config/scripts/structured-agent-session-start-failure-row-render.test.mjs": 2675, + "config/scripts/summarize-terminal-perf-report.test.mjs": 106, + "config/scripts/telemetry-bundle-constant-patterns.test.mjs": 60, + "config/scripts/terminal-ime-engagement-receipt.test.mjs": 60, + "config/scripts/terminal-partial-escape-native-continuation.test.mjs": 2681, + "config/scripts/terminal-perf-dependency-preparation.test.mjs": 613, + "config/scripts/terminal-perf-report-annotations.test.mjs": 59, + "config/scripts/test-only-source-path.test.mjs": 42, + "config/scripts/trim-windows-icon-source.test.mjs": 234, + "config/scripts/update-node-runtime-pin.test.mjs": 89, + "config/scripts/verify-cli-bin.test.mjs": 106, + "config/scripts/verify-dev-channel-packaging.test.mjs": 285, + "config/scripts/verify-linux-glibc-floor.test.mjs": 124, + "config/scripts/verify-linux-glibc-payloads.test.mjs": 156, + "config/scripts/verify-localization-catalog.test.mjs": 514, + "config/scripts/verify-localization-catalogs.test.mjs": 2042, + "config/scripts/verify-localization-extraction.test.mjs": 49, + "config/scripts/verify-packaged-browser-participation.test.mjs": 61, + "config/scripts/verify-packaged-daemon-entry.test.mjs": 132, + "config/scripts/verify-packaged-mobile-web-bundle.test.mjs": 330, + "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 109, + "config/scripts/verify-packaged-orcad-template.test.mjs": 443, + "config/scripts/verify-packaged-plugin-resources.test.mjs": 91, + "config/scripts/verify-release-required-assets.test.mjs": 55, + "config/scripts/verify-skills-cli-runtime.test.mjs": 289, + "config/scripts/verify-windows-inner-signature.test.mjs": 68, + "config/scripts/verify-wsl-e2e-participation.test.mjs": 77, + "config/scripts/vitest-node-runtime-boundary.test.ts": 2060, + "config/scripts/vitest-node-runtime-pool.test.ts": 341, + "config/scripts/vitest-real-agent-home-write-guard.test.ts": 66, + "config/scripts/vitest-runtime-boundary.test.ts": 114, + "config/scripts/vitest-runtime-sequencer.test.mjs": 194, + "config/scripts/vitest-sqlite-runtime-boundary.test.ts": 3901, + "config/scripts/websocket-server-loopback-bind.test.ts": 536, + "config/scripts/win-crash-survival-e2e.test.mjs": 584, + "config/scripts/win32-test-lane-registration.test.mjs": 2948, + "config/scripts/windows-bin-spawn-predicate.test.mjs": 236, + "config/scripts/windows-cmd-shim-spawn-boundary.test.mjs": 1437, + "config/scripts/windows-pe-imports.test.mjs": 48, + "config/scripts/windows-pe-machine.test.mjs": 66, + "config/scripts/windows-process-tree-creation-time.test.mjs": 45, + "config/scripts/windows-process-tree-gyp-path.test.mjs": 39, + "config/scripts/windows-process-tree-gyp-rebuild.test.mjs": 80, + "config/scripts/windows-process-tree-patch-contract.test.mjs": 562, + "config/scripts/windows-pty-table-stress-observer.test.mjs": 97, + "config/scripts/windows-uninstaller-signing.test.mjs": 63, + "config/scripts/workflow-ref-mirror-case-safety.test.mjs": 534, + "config/scripts/workflow-ref-reachability.test.mjs": 662, + "config/scripts/workspace-source-exports.test.mjs": 120, + "config/scripts/wsl-e2e-lane-contract.test.mjs": 166, + "config/scripts/xterm-image-lifecycle-contract.test.mjs": 231, + "config/scripts/xterm-image-memory-contract.test.mjs": 380, + "config/scripts/xterm-image-resize-contract.test.mjs": 73, + "config/scripts/xterm-image-wasm-budget-contract.test.mjs": 479, + "config/scripts/zip-extractor-command.test.mjs": 76, + "src/cli/agent-context.test.ts": 125, + "src/cli/agent-session-search-format.test.ts": 71, + "src/cli/args-repeatable-flags.test.ts": 69, + "src/cli/args.test.ts": 123, + "src/cli/automation-format.test.ts": 102, + "src/cli/automation-owner-conflict-recovery.test.ts": 482, + "src/cli/browser-cookie-credentials-empty-value.test.ts": 206, + "src/cli/browser-storage-empty-value.test.ts": 185, + "src/cli/browser.test.ts": 309, + "src/cli/cli-version.test.ts": 56, + "src/cli/codex-command-classification.test.ts": 56, + "src/cli/command-suggestion-budget.test.ts": 55, + "src/cli/command-suggestion.test.ts": 53, + "src/cli/computer-format.test.ts": 102, + "src/cli/emulator-logcat-format.test.ts": 48, + "src/cli/execution-host-flag.test.ts": 94, + "src/cli/format-recovery.test.ts": 355, + "src/cli/format.test.ts": 618, + "src/cli/handler-group-manifest.test.ts": 1856, + "src/cli/handlers/account.test.ts": 924, + "src/cli/handlers/agent-hooks-load-boundary.test.ts": 533, + "src/cli/handlers/agent-hooks-prepare-codex-isolated-home.test.ts": 531, + "src/cli/handlers/agent-hooks.test.ts": 1290, + "src/cli/handlers/artifacts.test.ts": 475, + "src/cli/handlers/automation-destination-fencing.test.ts": 515, + "src/cli/handlers/automation-owner-fencing.test.ts": 506, + "src/cli/handlers/computer-action-routing.test.ts": 200, + "src/cli/handlers/computer-action-validation.test.ts": 250, + "src/cli/handlers/computer-state-formatting.test.ts": 191, + "src/cli/handlers/computer.test.ts": 224, + "src/cli/handlers/core.test.ts": 385, + "src/cli/handlers/data-account-commands.test.ts": 419, + "src/cli/handlers/emulator.test.ts": 207, + "src/cli/handlers/file-absolute-paths.test.ts": 200, + "src/cli/handlers/file.test.ts": 181, + "src/cli/handlers/interactive-login-interruption.test.ts": 69, + "src/cli/handlers/linear.test.ts": 267, + "src/cli/handlers/managed-server.test.ts": 432, + "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 378, + "src/cli/handlers/orchestration-check-identity.test.ts": 473, + "src/cli/handlers/orchestration-federated-legacy-settlement.test.ts": 504, + "src/cli/handlers/orchestration-gate-cli.test.ts": 263, + "src/cli/handlers/orchestration-legacy-read-only.test.ts": 476, + "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 433, + "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 484, + "src/cli/handlers/orchestration-migration.test.ts": 555, + "src/cli/handlers/orchestration-module-boundaries.test.ts": 534, + "src/cli/handlers/orchestration-request-show-cli.test.ts": 442, + "src/cli/handlers/orchestration-run-cli.test.ts": 455, + "src/cli/handlers/orchestration-send-receipt-warnings.test.ts": 375, + "src/cli/handlers/orchestration-task-create-cli.test.ts": 561, + "src/cli/handlers/orchestration-task-list-brief.test.ts": 436, + "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 496, + "src/cli/handlers/orchestration-worker-cli.test.ts": 472, + "src/cli/handlers/orchestration-worker-show-wait-cli.test.ts": 376, + "src/cli/handlers/orchestration.test.ts": 805, + "src/cli/handlers/orchestration/mutation-request.test.ts": 3412, + "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 534, + "src/cli/handlers/orchestration/worker-output.test.ts": 66, + "src/cli/handlers/profile-state-recovery-admission.test.ts": 2338, + "src/cli/handlers/profile-state.test.ts": 1185, + "src/cli/handlers/repo-visibility.test.ts": 584, + "src/cli/handlers/search.test.ts": 359, + "src/cli/handlers/skill-sharing.test.ts": 492, + "src/cli/handlers/terminal.test.ts": 571, + "src/cli/handlers/worktree-gitlab-link.test.ts": 347, + "src/cli/host-selector-alternatives.test.ts": 82, + "src/cli/index-automation-extra-agent-args.test.ts": 318, + "src/cli/index-automation-identifiers.test.ts": 293, + "src/cli/index-automation-schedule.test.ts": 256, + "src/cli/index-automation-session-reuse.test.ts": 312, + "src/cli/index-automation-source-context.test.ts": 254, + "src/cli/index-automation-target.test.ts": 246, + "src/cli/index-device-commands.test.ts": 180, + "src/cli/index-environment-commands.test.ts": 408, + "src/cli/index-local-command-routing-flags.test.ts": 764, + "src/cli/index-memory-diagnostics.test.ts": 244, + "src/cli/index-omitted-host-scope-selectors.test.ts": 317, + "src/cli/index-orchestration.test.ts": 292, + "src/cli/index-project-setup.test.ts": 245, + "src/cli/index-reference.test.ts": 233, + "src/cli/index-serve-command.test.ts": 444, + "src/cli/index-terminal-commands.test.ts": 233, + "src/cli/index-terminal-list-host-scope.test.ts": 229, + "src/cli/index-vm-recipe-doctor.test.ts": 469, + "src/cli/index-worktree-create-agent.test.ts": 253, + "src/cli/index-worktree-create-linear.test.ts": 235, + "src/cli/index-worktree-create-parent.test.ts": 310, + "src/cli/index-worktree-create-target.test.ts": 273, + "src/cli/index-worktree-gitlab-links.test.ts": 225, + "src/cli/index-worktree-linked-items.test.ts": 299, + "src/cli/index-worktree-selector-resolution.test.ts": 527, + "src/cli/index-worktree-set.test.ts": 281, + "src/cli/index.test.ts": 1022, + "src/cli/linear-format.test.ts": 66, + "src/cli/orchestration-dispatch-refusal-format.test.ts": 101, + "src/cli/orchestration-mutation-recovery.test.ts": 461, + "src/cli/orchestration-session-caller-cli.test.ts": 608, + "src/cli/orchestration-structured-sender-identity.test.ts": 285, + "src/cli/orchestration-structured-session-no-identity.test.ts": 448, + "src/cli/quote-stripped-json-flag.test.ts": 62, + "src/cli/reference-command-arguments.test.ts": 132, + "src/cli/retry-request-flag.test.ts": 513, + "src/cli/runtime-client-deferral.test.ts": 939, + "src/cli/runtime-client.test.ts": 532, + "src/cli/runtime/client-recovery.test.ts": 1089, + "src/cli/runtime/client-timeout-policy.test.ts": 371, + "src/cli/runtime/envelope-schema.test.ts": 261, + "src/cli/runtime/environments.test.ts": 409, + "src/cli/runtime/launch-serve-runtime.test.ts": 328, + "src/cli/runtime/launch-user-data-path.test.ts": 264, + "src/cli/runtime/launch.test.ts": 369, + "src/cli/runtime/orchestration-recovery-command.test.ts": 59, + "src/cli/runtime/profile-state-recovery-launch.test.ts": 301, + "src/cli/runtime/runtime-access-denied.test.ts": 433, + "src/cli/runtime/runtime-source-fence.test.ts": 454, + "src/cli/runtime/serve-orcad-launch.test.ts": 287, + "src/cli/runtime/serve-signal-exit-diagnostic.test.ts": 92, + "src/cli/runtime/status-caller.test.ts": 660, + "src/cli/runtime/status.test.ts": 377, + "src/cli/runtime/transport-framing.test.ts": 337, + "src/cli/runtime/transport-ssh-bridge-credential.test.ts": 248, + "src/cli/runtime/transport.test.ts": 789, + "src/cli/runtime/types.test.ts": 48, + "src/cli/runtime/websocket-transport-error.test.ts": 375, + "src/cli/runtime/websocket-transport.test.ts": 552, + "src/cli/search-command-arguments.test.ts": 352, + "src/cli/serve-electron-flag-parity.test.ts": 85, + "src/cli/shell-command-quote.test.ts": 53, + "src/cli/skills-reference-selector.test.ts": 206, + "src/cli/skills.test.ts": 1353, + "src/cli/specs/account.test.ts": 94, + "src/cli/specs/bundled-guide-flags.test.ts": 201, + "src/cli/specs/file.test.ts": 97, + "src/cli/specs/profile-state.test.ts": 87, + "src/cli/specs/search.test.ts": 181, + "src/cli/ssh-bridge-command-policy.test.ts": 70, + "src/cli/terminal-format-draft.test.ts": 51, + "src/cli/terminal-format.test.ts": 78, + "src/cli/terminal-list-host-scope-format.test.ts": 80, + "src/cli/terminal-read-screen.test.ts": 215, + "src/cli/vocabulary-policy.test.ts": 110, + "src/cli/worktree-ps-terminal-verdict.test.ts": 48, + "src/cli/worktree-selector-caller-host.test.ts": 69, + "src/cli/worktree-selector-wsl-posix-path.test.ts": 78, + "src/main/acp/acp-agent-connection.test.ts": 354, + "src/main/acp/acp-compaction-journal-fixture.test.ts": 298, + "src/main/acp/acp-dialects/grok-subagents.test.ts": 307, + "src/main/acp/acp-dialects/omp-dialect.test.ts": 258, + "src/main/acp/acp-fixture-privacy.test.ts": 137, + "src/main/acp/acp-json-rpc-peer.test.ts": 274, + "src/main/acp/acp-model-catalog-probe.test.ts": 426, + "src/main/acp/acp-permission-requests.test.ts": 355, + "src/main/acp/acp-prompt-content.test.ts": 641, + "src/main/acp/acp-recovery-history.test.ts": 502, + "src/main/acp/acp-session-agent-turns.test.ts": 297, + "src/main/acp/acp-session-lifecycle.test.ts": 273, + "src/main/acp/acp-session-notifications.test.ts": 323, + "src/main/acp/acp-session-runtime.test.ts": 459, + "src/main/acp/acp-structured-auth.test.ts": 1129, + "src/main/acp/acp-structured-background-task-stop.test.ts": 4451, + "src/main/acp/acp-structured-broken-stream.test.ts": 10364, + "src/main/acp/acp-structured-child-session-end.test.ts": 2649, + "src/main/acp/acp-structured-child-stop.test.ts": 2928, + "src/main/acp/acp-structured-child-work-admission.test.ts": 1464, + "src/main/acp/acp-structured-child-work.test.ts": 2920, + "src/main/acp/acp-structured-compaction.test.ts": 895, + "src/main/acp/acp-structured-context-clear.test.ts": 490, + "src/main/acp/acp-structured-electron-free.test.ts": 276, + "src/main/acp/acp-structured-grok-subagent-recordings.test.ts": 1106, + "src/main/acp/acp-structured-host-configured-default.test.ts": 1598, + "src/main/acp/acp-structured-host-crash.test.ts": 1006, + "src/main/acp/acp-structured-host-lifecycle.test.ts": 2036, + "src/main/acp/acp-structured-host-live-listing.test.ts": 1281, + "src/main/acp/acp-structured-host-option-hang.test.ts": 1480, + "src/main/acp/acp-structured-host-restore-failed.test.ts": 1530, + "src/main/acp/acp-structured-host-start-aborts.test.ts": 1092, + "src/main/acp/acp-structured-host-stop.test.ts": 1968, + "src/main/acp/acp-structured-host-subagent-stop.test.ts": 1665, + "src/main/acp/acp-structured-image-prompts.test.ts": 806, + "src/main/acp/acp-structured-launch-resolution-omp.test.ts": 415, + "src/main/acp/acp-structured-launch-resolution-opencode.test.ts": 317, + "src/main/acp/acp-structured-launch-resolution.test.ts": 524, + "src/main/acp/acp-structured-launch-visuals.test.ts": 364, + "src/main/acp/acp-structured-missing-model.test.ts": 361, + "src/main/acp/acp-structured-omp-recordings.test.ts": 953, + "src/main/acp/acp-structured-opencode-recordings.test.ts": 690, + "src/main/acp/acp-structured-options.test.ts": 398, + "src/main/acp/acp-structured-prompt-failure.test.ts": 759, + "src/main/acp/acp-structured-request-admission.test.ts": 824, + "src/main/acp/acp-structured-session-adapter-lifecycle.test.ts": 4918, + "src/main/acp/acp-structured-session-adapter-recordings.test.ts": 684, + "src/main/acp/acp-structured-session-adapter-resume.test.ts": 5492, + "src/main/acp/acp-structured-session-adapter-teardown.test.ts": 1211, + "src/main/acp/acp-structured-session-adapter.test.ts": 1251, + "src/main/acp/acp-structured-sign-in.test.ts": 813, + "src/main/acp/acp-structured-steer.test.ts": 1740, + "src/main/acp/acp-subagent-timeline.test.ts": 476, + "src/main/acp/acp-text-message-ownership.test.ts": 1219, + "src/main/acp/acp-text-stream-identity.test.ts": 817, + "src/main/acp/acp-timeline-background-task-evidence.test.ts": 698, + "src/main/acp/acp-timeline-background-task-results.test.ts": 594, + "src/main/acp/acp-timeline-background-task-words.test.ts": 1032, + "src/main/acp/acp-timeline-background-tasks.test.ts": 1081, + "src/main/acp/acp-timeline-dialect.test.ts": 618, + "src/main/acp/acp-timeline-generic.test.ts": 974, + "src/main/acp/acp-timeline-grok-subagent-retention.test.ts": 1206, + "src/main/acp/acp-timeline-grok-subagents.test.ts": 1171, + "src/main/acp/acp-timeline-joins.test.ts": 561, + "src/main/acp/acp-timeline-open-enums.test.ts": 585, + "src/main/acp/acp-timeline-opencode-dialect.test.ts": 415, + "src/main/acp/acp-timeline-recordings.test.ts": 610, + "src/main/acp/acp-timeline-recovery.test.ts": 929, + "src/main/acp/acp-timeline-routing.test.ts": 725, + "src/main/acp/acp-timeline-turn-failures.test.ts": 863, + "src/main/acp/acp-visuals-skill.test.ts": 53, + "src/main/acp/acp-write-queue-retention.test.ts": 466, + "src/main/active-view-persistence-boundary.test.ts": 1716, + "src/main/active-view-preference-sync-flush-veto.test.ts": 784, + "src/main/active-view-preference.test.ts": 62, + "src/main/agent-auth-restart-preservation.test.ts": 75, + "src/main/agent-awake-service-platform-assertions.test.ts": 93, + "src/main/agent-awake-service.test.ts": 127, + "src/main/agent-cli-version-probe.test.ts": 63, + "src/main/agent-hooks/branch-rename-failure-output.test.ts": 51, + "src/main/agent-hooks/ended-process-reconciliation.test.ts": 638, + "src/main/agent-hooks/first-work-branch-rename.test.ts": 870, + "src/main/agent-hooks/first-work-folder-rename.test.ts": 118, + "src/main/agent-hooks/grok-replay-guard.test.ts": 149, + "src/main/agent-hooks/hook-process-capture.test.ts": 100, + "src/main/agent-hooks/hook-script-outside-orca.test.ts": 277, + "src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 647, + "src/main/agent-hooks/install-telemetry.test.ts": 50, + "src/main/agent-hooks/installer-utils-remote.test.ts": 71, + "src/main/agent-hooks/installer-utils.test.ts": 189, + "src/main/agent-hooks/local-agent-cli-presence.test.ts": 64, + "src/main/agent-hooks/local-tmux-managed-pty.test.ts": 49, + "src/main/agent-hooks/managed-agent-hook-controls.test.ts": 127, + "src/main/agent-hooks/managed-hook-command-contract.test.ts": 389, + "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 68, + "src/main/agent-hooks/managed-hook-install-lock.test.ts": 332, + "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 99, + "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 410, + "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 498, + "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 1579, + "src/main/agent-hooks/managed-hook-timeout.test.ts": 1842, + "src/main/agent-hooks/managed-toml-ownership.test.ts": 65, + "src/main/agent-hooks/manual-compact-hook-stream.test.ts": 806, + "src/main/agent-hooks/manual-compact-status-cleanup.test.ts": 635, + "src/main/agent-hooks/migration-unsupported-pty-state.test.ts": 44, + "src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts": 3465, + "src/main/agent-hooks/remote-hook-service-installers.test.ts": 453, + "src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts": 430, + "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 821, + "src/main/agent-hooks/server-agent-presence.test.ts": 732, + "src/main/agent-hooks/server-ai-vault-liveness.test.ts": 669, + "src/main/agent-hooks/server-amp-normalization.test.ts": 693, + "src/main/agent-hooks/server-authority-evidence.test.ts": 729, + "src/main/agent-hooks/server-claude-cancel-captures.test.ts": 761, + "src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts": 713, + "src/main/agent-hooks/server-claude-normalization.test.ts": 621, + "src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts": 758, + "src/main/agent-hooks/server-claude-permission-visibility.test.ts": 827, + "src/main/agent-hooks/server-claude-statusline.test.ts": 737, + "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts": 6962, + "src/main/agent-hooks/server-claude-terminal-interrupt.test.ts": 728, + "src/main/agent-hooks/server-closed-tab-suppression.test.ts": 755, + "src/main/agent-hooks/server-codex-ctrl-c-inference.test.ts": 663, + "src/main/agent-hooks/server-codex-interrupt-hook.test.ts": 558, + "src/main/agent-hooks/server-codex-normalization.test.ts": 684, + "src/main/agent-hooks/server-codex-subagent-transcript.test.ts": 4877, + "src/main/agent-hooks/server-codex-turn-interruption.test.ts": 4222, + "src/main/agent-hooks/server-copilot-normalization.test.ts": 940, + "src/main/agent-hooks/server-cursor-normalization.test.ts": 653, + "src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts": 770, + "src/main/agent-hooks/server-escape-navigation-inference.test.ts": 696, + "src/main/agent-hooks/server-grok-background-status.test.ts": 650, + "src/main/agent-hooks/server-grok-cancel.test.ts": 677, + "src/main/agent-hooks/server-grok-discovery.test.ts": 1216, + "src/main/agent-hooks/server-hook-http-ingest.test.ts": 742, + "src/main/agent-hooks/server-ingest-remote.test.ts": 648, + "src/main/agent-hooks/server-ingest-structured-children.test.ts": 615, + "src/main/agent-hooks/server-ingest-structured-state-clock.test.ts": 679, + "src/main/agent-hooks/server-ingest-structured-status.test.ts": 719, + "src/main/agent-hooks/server-ingest-terminal-status.test.ts": 711, + "src/main/agent-hooks/server-interrupt-inference-guards.test.ts": 580, + "src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts": 644, + "src/main/agent-hooks/server-interrupt-inference-validation.test.ts": 670, + "src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts": 814, + "src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts": 742, + "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts": 854, + "src/main/agent-hooks/server-last-status-lead-boundary.test.ts": 852, + "src/main/agent-hooks/server-last-status-main-agent-fact.test.ts": 802, + "src/main/agent-hooks/server-last-status-restored-children.test.ts": 776, + "src/main/agent-hooks/server-last-status-write.test.ts": 1017, + "src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts": 1703, + "src/main/agent-hooks/server-muse-session-log-poll.test.ts": 2581, + "src/main/agent-hooks/server-observation-provenance.test.ts": 656, + "src/main/agent-hooks/server-omp-retired-ingress.test.ts": 649, + "src/main/agent-hooks/server-omp-retirement-ack.test.ts": 606, + "src/main/agent-hooks/server-opencode-binder.test.ts": 884, + "src/main/agent-hooks/server-opencode-lifecycle.test.ts": 867, + "src/main/agent-hooks/server-opencode-tui-identity.test.ts": 914, + "src/main/agent-hooks/server-pane-authority.test.ts": 875, + "src/main/agent-hooks/server-pi-normalization.test.ts": 868, + "src/main/agent-hooks/server-process-lifetime-status.test.ts": 964, + "src/main/agent-hooks/server-prompt-sent-telemetry.test.ts": 691, + "src/main/agent-hooks/server-relay-listener-replay.test.ts": 657, + "src/main/agent-hooks/server-relayed-claude-cancel.test.ts": 960, + "src/main/agent-hooks/server-reminted-pane-key.test.ts": 739, + "src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts": 739, + "src/main/agent-hooks/server-removed-worktree-status.test.ts": 574, + "src/main/agent-hooks/server-replay-evidence-clock.test.ts": 631, + "src/main/agent-hooks/server-retired-pane-new-turn.test.ts": 650, + "src/main/agent-hooks/server-start-failure-lifecycle.test.ts": 656, + "src/main/agent-hooks/server-startup-prompt-control.test.ts": 534, + "src/main/agent-hooks/server-status-listener-fanout.test.ts": 725, + "src/main/agent-hooks/server-status-persist-throttle.test.ts": 962, + "src/main/agent-hooks/server-structured-canonical-status.test.ts": 678, + "src/main/agent-hooks/server-tmux-canonical.test.ts": 675, + "src/main/agent-hooks/server-tmux-wire.test.ts": 644, + "src/main/agent-hooks/server-transport-interference.test.ts": 11601, + "src/main/agent-hooks/server-turn-started-at.test.ts": 691, + "src/main/agent-hooks/server.claude-interactive-question.test.ts": 717, + "src/main/agent-hooks/server.test-fixtures.test.ts": 473, + "src/main/agent-hooks/server/server-row-ownership.test.ts": 612, + "src/main/agent-hooks/spool.test.ts": 974, + "src/main/agent-hooks/terminal-handle-row-identity.test.ts": 656, + "src/main/agent-hooks/windows-batch-hook-launcher.test.ts": 99, + "src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts": 124, + "src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts": 107, + "src/main/agent-hooks/windows-hook-command-hosts.test.ts": 75, + "src/main/agent-hooks/windows-hook-payload-delivery.test.ts": 129, + "src/main/agent-hooks/windows-hook-post-interpreter.test.ts": 419, + "src/main/agent-hooks/windows-powershell-hook-launcher.test.ts": 50, + "src/main/agent-hooks/wsl-guest-plugin-install.test.ts": 56, + "src/main/agent-hooks/wsl-hook-relay-launch.test.ts": 134, + "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts": 2021, + "src/main/agent-hooks/wsl-hook-relay-manager.test.ts": 2367, + "src/main/agent-hooks/wsl-hook-relay-reattach.test.ts": 971, + "src/main/agent-hooks/wsl-hook-relay-recovery.test.ts": 49, + "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts": 64, + "src/main/agent-hooks/wsl-opencode-install-race.test.ts": 869, + "src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts": 1862, + "src/main/agent-hooks/wsl-opencode-settings.test.ts": 1034, + "src/main/agent-launch/agent-launch-decided-mode-contract.test.ts": 48, + "src/main/agent-launch/agent-launch-executor.test.ts": 1177, + "src/main/agent-launch/agent-launch-legacy-host-policy.test.ts": 894, + "src/main/agent-launch/agent-launch-opencode-model.test.ts": 757, + "src/main/agent-launch/agent-launch-pane-attachment.test.ts": 273, + "src/main/agent-launch/startup-agent-worktree-create.test.ts": 1128, + "src/main/agent-state-file-reader.test.ts": 103, + "src/main/agent-trust-presets.test.ts": 300, + "src/main/agent-workspace-trust-guard-failure.test.ts": 119, + "src/main/agent-workspace-trust-spawn.test.ts": 274, + "src/main/agent-workspace-trust.test.ts": 113, + "src/main/ai-vault-search/session-search-agent-compatibility.test.ts": 319, + "src/main/ai-vault-search/session-search-child-service.test.ts": 534, + "src/main/ai-vault-search/session-search-content-hash.test.ts": 65, + "src/main/ai-vault-search/session-search-cwd-key.test.ts": 72, + "src/main/ai-vault-search/session-search-deleted-sources.test.ts": 416, + "src/main/ai-vault-search/session-search-directory-listings.test.ts": 47, + "src/main/ai-vault-search/session-search-engine.test.ts": 1393, + "src/main/ai-vault-search/session-search-file-id.test.ts": 274, + "src/main/ai-vault-search/session-search-file-write.test.ts": 500, + "src/main/ai-vault-search/session-search-fts5-contract.test.ts": 152, + "src/main/ai-vault-search/session-search-hit-ranking.test.ts": 73, + "src/main/ai-vault-search/session-search-host-registration.test.ts": 1096, + "src/main/ai-vault-search/session-search-identifier-split.test.ts": 51, + "src/main/ai-vault-search/session-search-index-consumer.test.ts": 280, + "src/main/ai-vault-search/session-search-index-generation.test.ts": 421, + "src/main/ai-vault-search/session-search-index-pass.test.ts": 468, + "src/main/ai-vault-search/session-search-index-writer.test.ts": 482, + "src/main/ai-vault-search/session-search-indexer.test.ts": 2536, + "src/main/ai-vault-search/session-search-instance.test.ts": 1147, + "src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts": 2756, + "src/main/ai-vault-search/session-search-live-transcript.test.ts": 423, + "src/main/ai-vault-search/session-search-merged-roots.test.ts": 489, + "src/main/ai-vault-search/session-search-message-rows.test.ts": 149, + "src/main/ai-vault-search/session-search-native-chat-indexing.test.ts": 476, + "src/main/ai-vault-search/session-search-opencode-index.test.ts": 807, + "src/main/ai-vault-search/session-search-orphan-rows.test.ts": 235, + "src/main/ai-vault-search/session-search-paging.test.ts": 601, + "src/main/ai-vault-search/session-search-query-planner.test.ts": 59, + "src/main/ai-vault-search/session-search-read-decision.test.ts": 49, + "src/main/ai-vault-search/session-search-retention-delete.test.ts": 198, + "src/main/ai-vault-search/session-search-retention-policy.test.ts": 275, + "src/main/ai-vault-search/session-search-retrieval-statement-compilation.test.ts": 501, + "src/main/ai-vault-search/session-search-root-refresh.test.ts": 623, + "src/main/ai-vault-search/session-search-row-filter.test.ts": 195, + "src/main/ai-vault-search/session-search-row-identity.test.ts": 148, + "src/main/ai-vault-search/session-search-scan-roots.test.ts": 230, + "src/main/ai-vault-search/session-search-schema.test.ts": 531, + "src/main/ai-vault-search/session-search-scope-entry-points.test.ts": 4400, + "src/main/ai-vault-search/session-search-scope-resolution.test.ts": 106, + "src/main/ai-vault-search/session-search-scope-service.test.ts": 288, + "src/main/ai-vault-search/session-search-service-registry.test.ts": 297, + "src/main/ai-vault-search/session-search-service.test.ts": 311, + "src/main/ai-vault-search/session-search-sidebar-parity.test.ts": 241, + "src/main/ai-vault-search/session-search-snippet-marks.test.ts": 179, + "src/main/ai-vault-search/session-search-sqlite-support.test.ts": 62, + "src/main/ai-vault-search/session-search-store-is-memory.test.ts": 827, + "src/main/ai-vault-search/session-search-store-scope-catalog.test.ts": 52, + "src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 313, + "src/main/ai-vault-search/session-search-typo-policy.test.ts": 160, + "src/main/ai-vault-search/session-search-typo-scope.test.ts": 148, + "src/main/ai-vault-search/session-search-write-lifetime.test.ts": 351, + "src/main/ai-vault/ai-vault-scan-cancellation.test.ts": 46, + "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 55, + "src/main/ai-vault/antigravity-ide-history.test.ts": 322, + "src/main/ai-vault/antigravity-index-file-admission.test.ts": 115, + "src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts": 157, + "src/main/ai-vault/antigravity-recorded-history.test.ts": 281, + "src/main/ai-vault/cached-session-list-wsl-probe.test.ts": 472, + "src/main/ai-vault/cached-session-list.test.ts": 626, + "src/main/ai-vault/claude-project-dir-encoding.test.ts": 52, + "src/main/ai-vault/codex-session-collection.test.ts": 149, + "src/main/ai-vault/codex-session-root-dedup.test.ts": 156, + "src/main/ai-vault/local-log-tail-reader.test.ts": 69, + "src/main/ai-vault/omp-session-root.test.ts": 234, + "src/main/ai-vault/openclaw-session-layout.test.ts": 172, + "src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts": 1150, + "src/main/ai-vault/remote-session-content-lines.test.ts": 84, + "src/main/ai-vault/runtime-session-scanner.test.ts": 256, + "src/main/ai-vault/runtime-session-search-call.test.ts": 44, + "src/main/ai-vault/session-cwd-bucket-layouts.test.ts": 59, + "src/main/ai-vault/session-delete-target.test.ts": 224, + "src/main/ai-vault/session-delete.test.ts": 220, + "src/main/ai-vault/session-document-stream-boundaries.test.ts": 155, + "src/main/ai-vault/session-document-stream-contract.test.ts": 681, + "src/main/ai-vault/session-document-stream-parity.test.ts": 201, + "src/main/ai-vault/session-document-stream-projection.test.ts": 145, + "src/main/ai-vault/session-first-user-prompt-opencode.test.ts": 232, + "src/main/ai-vault/session-first-user-prompt-read.test.ts": 302, + "src/main/ai-vault/session-list-result-validation.test.ts": 235, + "src/main/ai-vault/session-list-results.test.ts": 136, + "src/main/ai-vault/session-newest-files.test.ts": 120, + "src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts": 278, + "src/main/ai-vault/session-parse-cache-bounds.test.ts": 498, + "src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts": 244, + "src/main/ai-vault/session-parse-cache-persistence.test.ts": 348, + "src/main/ai-vault/session-scan-cutoff.test.ts": 125, + "src/main/ai-vault/session-scanner-agent-root-overrides.test.ts": 601, + "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 114, + "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 337, + "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 265, + "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 326, + "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 186, + "src/main/ai-vault/session-scanner-claude-title.test.ts": 333, + "src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts": 353, + "src/main/ai-vault/session-scanner-cline-parser.test.ts": 311, + "src/main/ai-vault/session-scanner-codebuddy-scope.test.ts": 236, + "src/main/ai-vault/session-scanner-codebuddy-source.test.ts": 212, + "src/main/ai-vault/session-scanner-codex-dual-root.test.ts": 327, + "src/main/ai-vault/session-scanner-codex-fast-path.test.ts": 352, + "src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts": 99, + "src/main/ai-vault/session-scanner-codex-parser.test.ts": 152, + "src/main/ai-vault/session-scanner-codex-title-index.test.ts": 255, + "src/main/ai-vault/session-scanner-codex-tool-records.test.ts": 156, + "src/main/ai-vault/session-scanner-codex-workers.test.ts": 306, + "src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts": 318, + "src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts": 433, + "src/main/ai-vault/session-scanner-cutoff.test.ts": 599, + "src/main/ai-vault/session-scanner-dedup-batches.test.ts": 510, + "src/main/ai-vault/session-scanner-devin-contention.test.ts": 367, + "src/main/ai-vault/session-scanner-devin-db.test.ts": 493, + "src/main/ai-vault/session-scanner-devin-dedup.test.ts": 383, + "src/main/ai-vault/session-scanner-devin-parser.test.ts": 127, + "src/main/ai-vault/session-scanner-directory-reader.test.ts": 97, + "src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 120, + "src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 63, + "src/main/ai-vault/session-scanner-graph-parsers.test.ts": 138, + "src/main/ai-vault/session-scanner-grok-parser.test.ts": 124, + "src/main/ai-vault/session-scanner-grok-user-text.test.ts": 51, + "src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts": 121, + "src/main/ai-vault/session-scanner-injected-title.test.ts": 295, + "src/main/ai-vault/session-scanner-jcode-parser.test.ts": 127, + "src/main/ai-vault/session-scanner-jsonl-reader.test.ts": 324, + "src/main/ai-vault/session-scanner-kimi-index-cache.test.ts": 119, + "src/main/ai-vault/session-scanner-kimi-parser.test.ts": 201, + "src/main/ai-vault/session-scanner-kiro-parser.test.ts": 124, + "src/main/ai-vault/session-scanner-muse.test.ts": 277, + "src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts": 156, + "src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts": 361, + "src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts": 122, + "src/main/ai-vault/session-scanner-omp-title.test.ts": 161, + "src/main/ai-vault/session-scanner-opencode-cancellation.test.ts": 678, + "src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts": 64, + "src/main/ai-vault/session-scanner-opencode-native-worker.test.ts": 721, + "src/main/ai-vault/session-scanner-opencode-parser.test.ts": 126, + "src/main/ai-vault/session-scanner-opencode-process-admission.test.ts": 275, + "src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts": 124, + "src/main/ai-vault/session-scanner-opencode-sources.test.ts": 53, + "src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts": 1475, + "src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts": 398, + "src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts": 3525, + "src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts": 53, + "src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts": 3438, + "src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts": 54, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts": 148, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.test.ts": 118, + "src/main/ai-vault/session-scanner-opencode-sqlite.test.ts": 432, + "src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts": 144, + "src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts": 192, + "src/main/ai-vault/session-scanner-opencode2-message.test.ts": 86, + "src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts": 332, + "src/main/ai-vault/session-scanner-parse-cache-agents.test.ts": 541, + "src/main/ai-vault/session-scanner-parse-cache.test.ts": 310, + "src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts": 275, + "src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts": 172, + "src/main/ai-vault/session-scanner-pi-scope.test.ts": 394, + "src/main/ai-vault/session-scanner-preview-window-truncation.test.ts": 111, + "src/main/ai-vault/session-scanner-qoder.test.ts": 319, + "src/main/ai-vault/session-scanner-record-budget.test.ts": 393, + "src/main/ai-vault/session-scanner-recoverable-empty.test.ts": 338, + "src/main/ai-vault/session-scanner-roots.test.ts": 184, + "src/main/ai-vault/session-scanner-scope.test.ts": 339, + "src/main/ai-vault/session-scanner-service-cancellation.test.ts": 417, "src/main/ai-vault/session-scanner-service-client.test.ts": 502, - "src/main/ai-vault/session-scanner-service-entry-path.test.ts": 43, - "src/main/ai-vault/session-scanner-service-entry.test.ts": 541, - "src/main/ai-vault/session-scanner-service-env.test.ts": 63, - "src/main/ai-vault/session-scanner-service-restart-policy.test.ts": 47, - "src/main/ai-vault/session-scanner-service-root-request.test.ts": 31, - "src/main/ai-vault/session-scanner-service-root-response.test.ts": 228, - "src/main/ai-vault/session-scanner-service-search-roots.test.ts": 577, - "src/main/ai-vault/session-scanner-service-search.test.ts": 1214, - "src/main/ai-vault/session-scanner-service-spawn.test.ts": 435, - "src/main/ai-vault/session-scanner-timeline.test.ts": 75, - "src/main/ai-vault/session-scanner-unlimited-dedup.test.ts": 3562, - "src/main/ai-vault/session-scanner-values.test.ts": 92, - "src/main/ai-vault/session-scanner.test.ts": 343, - "src/main/ai-vault/session-sidecar-stat.test.ts": 25, - "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 173, - "src/main/ai-vault/session-title-file-reader.test.ts": 130, - "src/main/ai-vault/session-transcript-consumers.test.ts": 384, - "src/main/ai-vault/session-transcript-every-agent-capture.test.ts": 551, - "src/main/ai-vault/session-transcript-message-content.test.ts": 48, - "src/main/ai-vault/session-transcript-record-budget.test.ts": 63, - "src/main/ai-vault/ssh-session-list.test.ts": 288, - "src/main/ai-vault/structured-session-ownership.test.ts": 106, - "src/main/amp/hook-service.test.ts": 83, - "src/main/antigravity/hook-service.test.ts": 144, - "src/main/antigravity/windows-hook-payload-delivery.test.ts": 84, - "src/main/app-icon.test.ts": 79, - "src/main/app-relaunch.test.ts": 60, - "src/main/appimage-runtime-identity.test.ts": 91, - "src/main/appkit-scene-mutation.test.ts": 28, - "src/main/artifacts/artifact-cloud-config.test.ts": 54, - "src/main/artifacts/artifact-cloud-recovery.test.ts": 477, - "src/main/artifacts/artifact-cloud-service-races.test.ts": 642, - "src/main/artifacts/artifact-cloud-service.test.ts": 1148, - "src/main/artifacts/artifact-create-intent-store.test.ts": 411, - "src/main/artifacts/artifact-recovery-directory-fsync.test.ts": 76, - "src/main/artifacts/artifact-share-record-allocation.test.ts": 181, - "src/main/artifacts/artifact-share-record-store.test.ts": 300, - "src/main/asar-transparent-fs.test.ts": 58, - "src/main/automations/automation-dispatch-host-fence.test.ts": 5212, - "src/main/automations/automation-owner-fencing.test.ts": 3123, - "src/main/automations/automation-owner-migration.test.ts": 64, - "src/main/automations/automation-run-terminal-surface.test.ts": 3148, - "src/main/automations/automation-run-writer.test.ts": 190, - "src/main/automations/automation-skip-coalescing.test.ts": 5292, - "src/main/automations/automation-ssh-readoption-migration.test.ts": 63, - "src/main/automations/automation-update-host-retarget.test.ts": 2563, - "src/main/automations/automation-worker-durability.test.ts": 4821, - "src/main/automations/automation-workspace-host-attribution.test.ts": 4398, - "src/main/automations/automation-zero-grace-tick-latency.test.ts": 4426, - "src/main/automations/dispatch-refusal.test.ts": 64, - "src/main/automations/dispatch-tokens.test.ts": 62, - "src/main/automations/external-automation-manager-cache.test.ts": 111, - "src/main/automations/external-automation-owner-guard.test.ts": 73, - "src/main/automations/external-automation-probe-scheduler.test.ts": 150, - "src/main/automations/external-job-run-sorting.test.ts": 136, - "src/main/automations/external-manager-scoped.test.ts": 90, - "src/main/automations/external-manager.test.ts": 157, - "src/main/automations/headless-dispatch-durability.test.ts": 348, - "src/main/automations/headless-workspace-create.test.ts": 55, - "src/main/automations/hermes-cron-output.test.ts": 196, - "src/main/automations/precheck-runner.test.ts": 1128, - "src/main/automations/refused-manual-run.test.ts": 3358, - "src/main/automations/retained-run-reconciliation.test.ts": 4350, - "src/main/automations/run-completion-watcher.test.ts": 4513, - "src/main/automations/run-target-resolution.test.ts": 51, - "src/main/automations/runtime-terminal-run-observer.test.ts": 51, - "src/main/automations/schedule-drift-report.test.ts": 62, - "src/main/automations/service-precheck.test.ts": 1864, - "src/main/automations/service.test.ts": 10184, - "src/main/azure-devops/azure-devops-api-request.test.ts": 93, - "src/main/azure-devops/client.test.ts": 232, - "src/main/azure-devops/pull-request-creation.test.ts": 167, - "src/main/azure-devops/pull-request-mappers.test.ts": 39, - "src/main/azure-devops/repository-ref.test.ts": 57, - "src/main/bitbucket/client.test.ts": 248, - "src/main/bitbucket/credential-connection.test.ts": 186, - "src/main/bitbucket/credential-store.test.ts": 147, - "src/main/bitbucket/pull-request-creation.test.ts": 163, - "src/main/bitbucket/pull-request-mappers.test.ts": 43, - "src/main/bitbucket/repository-ref.test.ts": 47, - "src/main/bitbucket/status-no-decrypt.test.ts": 188, - "src/main/browser/agent-browser-bridge-capture-paint-hold.test.ts": 263, - "src/main/browser/agent-browser-bridge-command-transport.test.ts": 550, - "src/main/browser/agent-browser-bridge-keypress-input.test.ts": 246, - "src/main/browser/agent-browser-bridge-mouse-input.test.ts": 323, - "src/main/browser/agent-browser-bridge-navigation.test.ts": 484, - "src/main/browser/agent-browser-bridge-pointer-input.test.ts": 342, - "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts": 608, - "src/main/browser/agent-browser-bridge-tab-routing.test.ts": 501, - "src/main/browser/agent-browser-bridge-text-input.test.ts": 609, + "src/main/ai-vault/session-scanner-service-entry-path.test.ts": 50, + "src/main/ai-vault/session-scanner-service-entry-subagents.test.ts": 649, + "src/main/ai-vault/session-scanner-service-entry.test.ts": 813, + "src/main/ai-vault/session-scanner-service-env.test.ts": 69, + "src/main/ai-vault/session-scanner-service-restart-policy.test.ts": 54, + "src/main/ai-vault/session-scanner-service-root-request.test.ts": 50, + "src/main/ai-vault/session-scanner-service-root-response.test.ts": 219, + "src/main/ai-vault/session-scanner-service-search-roots.test.ts": 604, + "src/main/ai-vault/session-scanner-service-search.test.ts": 1251, + "src/main/ai-vault/session-scanner-service-spawn.test.ts": 501, + "src/main/ai-vault/session-scanner-timeline.test.ts": 140, + "src/main/ai-vault/session-scanner-unlimited-dedup.test.ts": 3871, + "src/main/ai-vault/session-scanner-values.test.ts": 114, + "src/main/ai-vault/session-scanner-zcode-sources.test.ts": 131, + "src/main/ai-vault/session-scanner-zcode.test.ts": 313, + "src/main/ai-vault/session-scanner.test.ts": 425, + "src/main/ai-vault/session-sidecar-stat.test.ts": 68, + "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 275, + "src/main/ai-vault/session-title-file-reader.test.ts": 126, + "src/main/ai-vault/session-transcript-consumers.test.ts": 386, + "src/main/ai-vault/session-transcript-every-agent-capture.test.ts": 680, + "src/main/ai-vault/session-transcript-message-content.test.ts": 212, + "src/main/ai-vault/session-transcript-record-budget.test.ts": 41, + "src/main/ai-vault/structured-session-ownership.test.ts": 283, + "src/main/amp/hook-service.test.ts": 70, + "src/main/antigravity/hook-service.test.ts": 210, + "src/main/antigravity/native-account-host.test.ts": 43, + "src/main/antigravity/native-account-launch.test.ts": 107, + "src/main/antigravity/native-account-service.test.ts": 84, + "src/main/antigravity/native-account-store.test.ts": 255, + "src/main/antigravity/native-credential-backend.test.ts": 71, + "src/main/antigravity/native-credential-codec.test.ts": 44, + "src/main/antigravity/native-macos-credentials.real.test.ts": 73, + "src/main/antigravity/native-macos-credentials.test.ts": 75, + "src/main/antigravity/posix-hook-invocation.test.ts": 5420, + "src/main/antigravity/windows-hook-json-post.test.ts": 9898, + "src/main/antigravity/windows-hook-payload-delivery.test.ts": 126, + "src/main/app-icon.test.ts": 80, + "src/main/app-relaunch.test.ts": 64, + "src/main/appimage-runtime-identity.test.ts": 87, + "src/main/appkit-scene-mutation.test.ts": 49, + "src/main/artifacts/artifact-cloud-config.test.ts": 60, + "src/main/artifacts/artifact-cloud-recovery.test.ts": 577, + "src/main/artifacts/artifact-cloud-service-races.test.ts": 512, + "src/main/artifacts/artifact-cloud-service.test.ts": 1194, + "src/main/artifacts/artifact-create-intent-store.test.ts": 245, + "src/main/artifacts/artifact-recovery-directory-fsync.test.ts": 90, + "src/main/artifacts/artifact-share-record-allocation.test.ts": 179, + "src/main/artifacts/artifact-share-record-store.test.ts": 240, + "src/main/asar-transparent-fs.test.ts": 53, + "src/main/automations/automation-dispatch-host-fence.test.ts": 1715, + "src/main/automations/automation-owner-fencing.test.ts": 1763, + "src/main/automations/automation-owner-migration.test.ts": 80, + "src/main/automations/automation-run-terminal-surface.test.ts": 4629, + "src/main/automations/automation-run-writer.test.ts": 292, + "src/main/automations/automation-skip-coalescing.test.ts": 3604, + "src/main/automations/automation-ssh-readoption-migration.test.ts": 68, + "src/main/automations/automation-update-host-retarget.test.ts": 1497, + "src/main/automations/automation-worker-durability.test.ts": 5967, + "src/main/automations/automation-workspace-host-attribution.test.ts": 1786, + "src/main/automations/automation-zero-grace-tick-latency.test.ts": 1950, + "src/main/automations/dispatch-refusal.test.ts": 71, + "src/main/automations/dispatch-tokens.test.ts": 88, + "src/main/automations/external-automation-manager-cache.test.ts": 83, + "src/main/automations/external-automation-owner-guard.test.ts": 320, + "src/main/automations/external-automation-probe-scheduler.test.ts": 175, + "src/main/automations/external-manager-scoped.test.ts": 404, + "src/main/automations/external-manager.test.ts": 359, + "src/main/automations/headless-automation-agent-launch.test.ts": 845, + "src/main/automations/headless-dispatch-durability.test.ts": 414, + "src/main/automations/headless-run-terminal-retention.test.ts": 120, + "src/main/automations/headless-workspace-create.test.ts": 49, + "src/main/automations/hermes-cron-output.test.ts": 118, + "src/main/automations/precheck-runner.test.ts": 1267, + "src/main/automations/refused-manual-run.test.ts": 6693, + "src/main/automations/retained-run-reconciliation.test.ts": 1759, + "src/main/automations/run-completion-watcher.test.ts": 2643, + "src/main/automations/run-target-resolution.test.ts": 69, + "src/main/automations/runtime-automation-service-retention.test.ts": 1414, + "src/main/automations/runtime-automation-service.test.ts": 1101, + "src/main/automations/runtime-terminal-run-observer-evidence.test.ts": 64, + "src/main/automations/runtime-terminal-run-observer.test.ts": 82, + "src/main/automations/schedule-drift-report.test.ts": 63, + "src/main/automations/service-precheck.test.ts": 1669, + "src/main/automations/service.test.ts": 1985, + "src/main/azure-devops/azure-devops-api-request.test.ts": 62, + "src/main/azure-devops/client.test.ts": 566, + "src/main/azure-devops/pull-request-creation.test.ts": 351, + "src/main/azure-devops/pull-request-mappers.test.ts": 46, + "src/main/azure-devops/repository-ref.test.ts": 362, + "src/main/bitbucket/client.test.ts": 411, + "src/main/bitbucket/credential-connection.test.ts": 130, + "src/main/bitbucket/credential-store.test.ts": 178, + "src/main/bitbucket/pull-request-creation.test.ts": 420, + "src/main/bitbucket/pull-request-mappers.test.ts": 40, + "src/main/bitbucket/repository-ref.test.ts": 360, + "src/main/bitbucket/status-no-decrypt.test.ts": 196, + "src/main/browser/agent-browser-bridge-capture-paint-hold.test.ts": 581, + "src/main/browser/agent-browser-bridge-command-transport.test.ts": 563, + "src/main/browser/agent-browser-bridge-keypress-input.test.ts": 336, + "src/main/browser/agent-browser-bridge-mouse-input.test.ts": 372, + "src/main/browser/agent-browser-bridge-navigation.test.ts": 473, + "src/main/browser/agent-browser-bridge-pointer-input.test.ts": 335, + "src/main/browser/agent-browser-bridge-raw-process-retention.test.ts": 485, + "src/main/browser/agent-browser-bridge-raw-process.test.ts": 1056, + "src/main/browser/agent-browser-bridge-screenshot-format.test.ts": 318, + "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts": 751, + "src/main/browser/agent-browser-bridge-tab-routing.test.ts": 488, + "src/main/browser/agent-browser-bridge-text-input.test.ts": 633, "src/main/browser/agent-browser-orphan-sweep.test.ts": 63, - "src/main/browser/agent-browser-process-environment.test.ts": 55, - "src/main/browser/agent-browser-session-reset.test.ts": 66, - "src/main/browser/browser-certificate-trust-controller.test.ts": 382, - "src/main/browser/browser-clicked-link-routing.test.ts": 565, - "src/main/browser/browser-client-download-relay.test.ts": 168, - "src/main/browser/browser-client-download-routing.test.ts": 185, - "src/main/browser/browser-client-file-channel-negotiation.test.ts": 185, - "src/main/browser/browser-client-file-channel-reconnect.test.ts": 486, - "src/main/browser/browser-client-host-attach-request.test.ts": 153, - "src/main/browser/browser-client-host-authority-replacement.test.ts": 138, - "src/main/browser/browser-client-host-command-dispatcher.test.ts": 154, - "src/main/browser/browser-client-host-command-retention.test.ts": 436, - "src/main/browser/browser-client-host-id.test.ts": 85, - "src/main/browser/browser-client-host-placement-preparation.test.ts": 93, - "src/main/browser/browser-client-host-published-url-wiring.test.ts": 592, - "src/main/browser/browser-client-host-reconciliation-command-order.test.ts": 214, - "src/main/browser/browser-client-network-route-registry.test.ts": 118, - "src/main/browser/browser-client-page-automation-runtime.test.ts": 52, - "src/main/browser/browser-client-page-command-executor-fencing.test.ts": 284, - "src/main/browser/browser-client-page-command-executor.test.ts": 216, - "src/main/browser/browser-client-page-command-integration.test.ts": 231, - "src/main/browser/browser-client-page-execution-host-supersession.test.ts": 203, - "src/main/browser/browser-client-page-inventory.test.ts": 212, - "src/main/browser/browser-client-page-metadata-transport.test.ts": 147, - "src/main/browser/browser-client-page-published-url.test.ts": 118, - "src/main/browser/browser-client-page-reconciliation-adapters.test.ts": 239, - "src/main/browser/browser-client-page-renderer-bridge.test.ts": 109, - "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts": 843, - "src/main/browser/browser-client-page-renderer-runtime.test.ts": 161, - "src/main/browser/browser-client-page-unavailability.test.ts": 212, - "src/main/browser/browser-client-page-upload-routing.test.ts": 273, - "src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts": 220, - "src/main/browser/browser-client-route-cookie-import.test.ts": 68, - "src/main/browser/browser-client-upload-command.test.ts": 139, - "src/main/browser/browser-client-upload-staging.test.ts": 346, - "src/main/browser/browser-client-upload-transfer.test.ts": 7621, - "src/main/browser/browser-cookie-clear-preserve.test.ts": 125, - "src/main/browser/browser-cookie-clear-store-lifecycle.test.ts": 89, - "src/main/browser/browser-cookie-clear-store.test.ts": 68, - "src/main/browser/browser-cookie-decryption.test.ts": 74, - "src/main/browser/browser-cookie-import-app-bound-prefix.test.ts": 400, - "src/main/browser/browser-cookie-import-clear-atomicity.test.ts": 71, - "src/main/browser/browser-cookie-import-concurrency.test.ts": 490, - "src/main/browser/browser-cookie-import-google-exclusion.test.ts": 358, - "src/main/browser/browser-cookie-import-partition-fidelity.test.ts": 482, - "src/main/browser/browser-cookie-import-partition-reland.electron.test.ts": 2260, - "src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts": 675, - "src/main/browser/browser-cookie-import-partition-success.electron.test.ts": 893, - "src/main/browser/browser-cookie-import-partition.electron.test.ts": 814, - "src/main/browser/browser-cookie-import-plan-writes.test.ts": 74, - "src/main/browser/browser-cookie-import-policy.test.ts": 248, - "src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts": 1224, - "src/main/browser/browser-cookie-import-replacement.test.ts": 204, - "src/main/browser/browser-cookie-import-route-partition-staging.test.ts": 314, - "src/main/browser/browser-cookie-import-scope.test.ts": 285, - "src/main/browser/browser-cookie-import-undecryptable.test.ts": 443, - "src/main/browser/browser-cookie-import-validated-partition.electron.test.ts": 1035, - "src/main/browser/browser-cookie-import-write.test.ts": 38, - "src/main/browser/browser-cookie-import.comet.test.ts": 737, - "src/main/browser/browser-cookie-import.helium.test.ts": 567, - "src/main/browser/browser-cookie-import.test.ts": 3476, - "src/main/browser/browser-cookie-public-suffix-scope.test.ts": 42, - "src/main/browser/browser-cookie-registrable-family.test.ts": 57, - "src/main/browser/browser-cookie-samesite.electron.test.ts": 898, - "src/main/browser/browser-cookie-source-partition.test.ts": 42, - "src/main/browser/browser-cookie-validation.test.ts": 40, - "src/main/browser/browser-download-destination.test.ts": 55, - "src/main/browser/browser-execution-host-storage-identity.test.ts": 146, - "src/main/browser/browser-google-auth-ua.test.ts": 32, - "src/main/browser/browser-grab-payload.test.ts": 39, - "src/main/browser/browser-guest-shortcut-forwarding.test.ts": 333, - "src/main/browser/browser-guest-wheel-zoom-scroll.test.ts": 34, - "src/main/browser/browser-host-client-identity.test.ts": 97, - "src/main/browser/browser-host-lease-reconnect-delay.test.ts": 34, - "src/main/browser/browser-identity-mode-record.test.ts": 82, - "src/main/browser/browser-identity-mode-store.test.ts": 100, - "src/main/browser/browser-manager-annotation-bridge.test.ts": 214, - "src/main/browser/browser-manager-auth-user-agent.test.ts": 263, - "src/main/browser/browser-manager-client-hosted-downloads.test.ts": 366, - "src/main/browser/browser-manager-destroyed-guest-downloads.test.ts": 165, - "src/main/browser/browser-manager-destroyed-guest.test.ts": 445, - "src/main/browser/browser-manager-downloads.test.ts": 320, - "src/main/browser/browser-manager-grab-capture.test.ts": 216, - "src/main/browser/browser-manager-grab-mode.test.ts": 305, - "src/main/browser/browser-manager-grab-selection.test.ts": 169, - "src/main/browser/browser-manager-grab-shortcuts.test.ts": 249, - "src/main/browser/browser-manager-guest-lifecycle.test.ts": 412, - "src/main/browser/browser-manager-guest-policy-profile.test.ts": 279, - "src/main/browser/browser-manager-guest-shortcuts.test.ts": 329, - "src/main/browser/browser-manager-guest-visibility.test.ts": 178, - "src/main/browser/browser-manager-load-failure-replay.test.ts": 280, - "src/main/browser/browser-manager-popup-child.test.ts": 189, - "src/main/browser/browser-manager-popup-routing.test.ts": 552, - "src/main/browser/browser-manager-viewport-override.test.ts": 1098, - "src/main/browser/browser-manager-viewport-ownership.test.ts": 298, - "src/main/browser/browser-manager-viewport-partial-failure.test.ts": 249, - "src/main/browser/browser-manager-worker-request-user-agent.test.ts": 208, - "src/main/browser/browser-network-deferred-socket.test.ts": 71, - "src/main/browser/browser-network-execution-route.test.ts": 102, - "src/main/browser/browser-network-tunnel-client-memory-budget.test.ts": 77, - "src/main/browser/browser-network-tunnel-client.test.ts": 107, - "src/main/browser/browser-network-tunnel-conformance.test.ts": 162, - "src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts": 71, - "src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts": 32, - "src/main/browser/browser-network-tunnel-session-stream-scoped-failure.test.ts": 47, - "src/main/browser/browser-network-tunnel-session.test.ts": 93, - "src/main/browser/browser-page-initiated-tab-budget.test.ts": 44, - "src/main/browser/browser-process-user-agent.test.ts": 40, - "src/main/browser/browser-route-dns-prefetch.electron.test.ts": 4575, - "src/main/browser/browser-route-guest-popups.test.ts": 126, - "src/main/browser/browser-route-h3-egress.electron.test.ts": 14983, - "src/main/browser/browser-route-identity.test.ts": 57, - "src/main/browser/browser-route-partition-binding-capacity.test.ts": 70, - "src/main/browser/browser-route-partition-binding-store.test.ts": 93, - "src/main/browser/browser-route-partition-migration.test.ts": 112, - "src/main/browser/browser-route-partition-stability.test.ts": 188, - "src/main/browser/browser-route-partition-storage-lifecycle.test.ts": 80, - "src/main/browser/browser-route-partition-storage-retirement.test.ts": 151, - "src/main/browser/browser-route-partition-storage-runtime.test.ts": 57, - "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts": 3440, - "src/main/browser/browser-route-prepared-page-rekey.test.ts": 45, - "src/main/browser/browser-route-session-registry.test.ts": 147, - "src/main/browser/browser-route-tcp-egress.electron.test.ts": 1248, - "src/main/browser/browser-route-webcontents-registry.test.ts": 264, - "src/main/browser/browser-route-webrtc-egress.electron.test.ts": 10714, - "src/main/browser/browser-screencast-dialog-settlement.test.ts": 116, - "src/main/browser/browser-screencast-lifecycle.test.ts": 69, - "src/main/browser/browser-screencast-snapshot-scaling.test.ts": 192, - "src/main/browser/browser-screencast-stream.test.ts": 729, - "src/main/browser/browser-session-cookie-staging.scoped.test.ts": 370, - "src/main/browser/browser-session-cookie-staging.test.ts": 204, - "src/main/browser/browser-session-partition-policies.test.ts": 102, - "src/main/browser/browser-session-partition-proxy-install.test.ts": 327, - "src/main/browser/browser-session-proxy.test.ts": 205, - "src/main/browser/browser-session-registry-identity.persistence.test.ts": 400, - "src/main/browser/browser-session-registry-import-boundary.test.ts": 33, - "src/main/browser/browser-session-registry.persistence.test.ts": 353, - "src/main/browser/browser-session-registry.test.ts": 745, - "src/main/browser/browser-session-startup.test.ts": 77, - "src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts": 657, - "src/main/browser/browser-session-ua-wire-identity-cross-context.electron.test.ts": 6292, - "src/main/browser/browser-session-ua-wire-identity.electron.test.ts": 9753, - "src/main/browser/browser-session-user-agent-migration-inspection.test.ts": 131, - "src/main/browser/browser-text-insertion.test.ts": 76, - "src/main/browser/browser-viewport-user-agent.test.ts": 43, - "src/main/browser/browser-webauthn-access.test.ts": 29, - "src/main/browser/browser-webauthn-account-picker.test.ts": 74, - "src/main/browser/browser-webauthn-profile-delete.test.ts": 305, - "src/main/browser/cdp-bridge-integration.test.ts": 7871, - "src/main/browser/cdp-bridge-state.test.ts": 86, - "src/main/browser/cdp-client-response-writer.test.ts": 856, - "src/main/browser/cdp-keyboard-us-layout.test.ts": 64, - "src/main/browser/cdp-print-to-pdf.test.ts": 63, - "src/main/browser/cdp-screenshot.test.ts": 105, - "src/main/browser/cdp-ws-proxy-focus-replay.test.ts": 336, - "src/main/browser/cdp-ws-proxy.test.ts": 2626, - "src/main/browser/chromium-cookie-snapshot.test.ts": 889, - "src/main/browser/client-route-cookie-import-source-store.test.ts": 103, - "src/main/browser/doc-preview-download-block-notice.test.ts": 47, - "src/main/browser/doc-preview-failure-notice.test.ts": 42, - "src/main/browser/doc-preview-file-reader.test.ts": 107, - "src/main/browser/doc-preview-grant-registry.test.ts": 101, - "src/main/browser/doc-preview-guest-policy.test.ts": 277, - "src/main/browser/doc-preview-protocol.test.ts": 171, - "src/main/browser/electron-debugger-lease.test.ts": 37, - "src/main/browser/electron-probe-display-launch.test.ts": 57, - "src/main/browser/execution-route-socket-duplex.test.ts": 103, - "src/main/browser/grab-guest-script.test.ts": 107, - "src/main/browser/local-ssh-browser-partition-identity.test.ts": 321, - "src/main/browser/local-ssh-browser-partitions.probe-cache.test.ts": 60, - "src/main/browser/local-ssh-browser-route.test.ts": 104, - "src/main/browser/offscreen-browser-backend-lifecycle.test.ts": 259, - "src/main/browser/offscreen-browser-backend.web-preferences.test.ts": 45, - "src/main/browser/paired-runtime-browser-client-host-composition.test.ts": 143, - "src/main/browser/paired-runtime-browser-client-host-identity-wiring.test.ts": 583, - "src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts": 482, - "src/main/browser/paired-runtime-browser-client-host-registry.test.ts": 33, - "src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts": 503, - "src/main/browser/paired-runtime-browser-client-host-runtime.test.ts": 766, - "src/main/browser/paired-runtime-browser-client-host.test.ts": 294, - "src/main/browser/paired-runtime-browser-host-command-admission.test.ts": 260, - "src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts": 880, - "src/main/browser/paired-runtime-browser-host-lease.test.ts": 856, - "src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts": 269, - "src/main/browser/paired-runtime-browser-network-route.test.ts": 1212, - "src/main/browser/popup-origin-bar-window.test.ts": 96, - "src/main/browser/remote-browser-socks-buffering.test.ts": 1704, - "src/main/browser/remote-browser-socks-server.test.ts": 290, - "src/main/browser/snapshot-engine-iframe-sessions.test.ts": 92, - "src/main/browser/snapshot-engine.test.ts": 45, - "src/main/browser/ssh-browser-network-execution-route.test.ts": 532, - "src/main/browser/system-ssh-socks-client-socket.test.ts": 49, - "src/main/browser/wsl-browser-network-execution-route.test.ts": 269, - "src/main/browser/wsl-browser-network-relay-launch.test.ts": 65, - "src/main/claude-accounts/claude-account-service-account-selection.test.ts": 175, - "src/main/claude-accounts/claude-account-service-add-account.test.ts": 190, - "src/main/claude-accounts/claude-account-service-api-parity.test.ts": 206, - "src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts": 235, - "src/main/claude-accounts/claude-account-service-credential-capture.test.ts": 214, - "src/main/claude-accounts/claude-account-service-login-process.test.ts": 499, - "src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts": 165, - "src/main/claude-accounts/claude-duplicate-account.test.ts": 27, - "src/main/claude-accounts/claude-login-completion.oracle.test.ts": 162, - "src/main/claude-accounts/claude-structured-auth-policy.test.ts": 57, - "src/main/claude-accounts/claude-windows-interactive-login.test.ts": 238, - "src/main/claude-accounts/keychain-config-directory-aliases.test.ts": 98, - "src/main/claude-accounts/keychain.test.ts": 108, - "src/main/claude-accounts/live-pty-gate.test.ts": 42, - "src/main/claude-accounts/oauth-refresh.test.ts": 95, - "src/main/claude-accounts/runtime-auth-path-materialization.test.ts": 294, - "src/main/claude-accounts/runtime-auth-service-account-switching.test.ts": 447, - "src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts": 472, - "src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts": 695, - "src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts": 255, - "src/main/claude-accounts/runtime-auth-service-materialization.test.ts": 355, - "src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts": 598, - "src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts": 278, - "src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts": 427, - "src/main/claude-accounts/runtime-paths.test.ts": 48, - "src/main/claude-accounts/runtime-selection.test.ts": 39, - "src/main/claude-accounts/windows-command-invocation.test.ts": 39, - "src/main/claude-usage/claude-model-pricing.test.ts": 47, - "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 57, - "src/main/claude-usage/scanner-large-directory.test.ts": 480, - "src/main/claude-usage/scanner-scan.test.ts": 128, - "src/main/claude-usage/scanner.test.ts": 61, - "src/main/claude-usage/store.test.ts": 157, - "src/main/claude-usage/transcript-record-parser-prefilter.test.ts": 62, - "src/main/claude-usage/worktree-attribution-scaling.test.ts": 63, - "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 3181, - "src/main/claude/claude-agent-sdk-control-requests.test.ts": 28, - "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 125, - "src/main/claude/claude-agent-sdk-exit-proof.test.ts": 12835, - "src/main/claude/claude-agent-sdk-import-boundary.test.ts": 1974, - "src/main/claude/claude-agent-sdk-process-spawn.test.ts": 87, - "src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 137, - "src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 133, - "src/main/claude/claude-background-task-resume.test.ts": 66, - "src/main/claude/claude-background-task-retention.test.ts": 585, - "src/main/claude/claude-background-task-row-journal.test.ts": 142, - "src/main/claude/claude-background-task-rows.test.ts": 316, - "src/main/claude/claude-background-task-terminal-frames.test.ts": 64, - "src/main/claude/claude-background-task-tracker.test.ts": 147, - "src/main/claude/claude-background-task-turn-resumption.test.ts": 108, - "src/main/claude/claude-child-process-environment.test.ts": 41, - "src/main/claude/claude-child-work-decoder.test.ts": 46, - "src/main/claude/claude-command-lifecycle-frames.test.ts": 109, - "src/main/claude/claude-config-dir-pin.test.ts": 40, - "src/main/claude/claude-context-model-switch.test.ts": 165, - "src/main/claude/claude-context-usage-restart.test.ts": 376, - "src/main/claude/claude-context-usage-unloaded-turn.test.ts": 1419, - "src/main/claude/claude-context-usage.test.ts": 124, - "src/main/claude/claude-context-window-hint.test.ts": 185, - "src/main/claude/claude-descendant-escalation-boundary.test.ts": 514, - "src/main/claude/claude-history-window-source-budget.test.ts": 610, - "src/main/claude/claude-model-catalog-probe.test.ts": 293, - "src/main/claude/claude-provisional-row-corrections.test.ts": 39, - "src/main/claude/claude-session-end-hook-capability.test.ts": 33, - "src/main/claude/claude-session-end-install.test.ts": 73, - "src/main/claude/claude-slash-command-catalog.test.ts": 64, - "src/main/claude/claude-stream-json-connection-close.test.ts": 462, - "src/main/claude/claude-stream-json-connection.test.ts": 24821, - "src/main/claude/claude-streamed-text-checkpoints.test.ts": 41, - "src/main/claude/claude-structured-applied-options.test.ts": 472, - "src/main/claude/claude-structured-auth-parity.test.ts": 638, - "src/main/claude/claude-structured-child-work-captures.test.ts": 755, - "src/main/claude/claude-structured-child-work-producer.test.ts": 835, - "src/main/claude/claude-structured-compaction.test.ts": 382, - "src/main/claude/claude-structured-content-parts.test.ts": 124, - "src/main/claude/claude-structured-control-actions.test.ts": 443, - "src/main/claude/claude-structured-dispatch-admission.test.ts": 405, - "src/main/claude/claude-structured-dispatch-boundary.test.ts": 459, - "src/main/claude/claude-structured-dispatch-content.test.ts": 46, - "src/main/claude/claude-structured-dispatch-queue-boundary.test.ts": 473, - "src/main/claude/claude-structured-dispatch.test.ts": 1776, - "src/main/claude/claude-structured-effort-reporting.test.ts": 496, - "src/main/claude/claude-structured-history-window.test.ts": 211, - "src/main/claude/claude-structured-inbound-control.test.ts": 78, - "src/main/claude/claude-structured-journal-prompt-retry.test.ts": 157, - "src/main/claude/claude-structured-journal-translation-background-tasks.test.ts": 346, - "src/main/claude/claude-structured-journal-translation-context-usage.test.ts": 417, - "src/main/claude/claude-structured-journal-translation-subagents.test.ts": 589, - "src/main/claude/claude-structured-journal-translation-turn-timing.test.ts": 524, - "src/main/claude/claude-structured-journal-translation.plan.test.ts": 87, - "src/main/claude/claude-structured-journal-translation.test.ts": 369, - "src/main/claude/claude-structured-launch-resolution.test.ts": 396, - "src/main/claude/claude-structured-location-support.test.ts": 65, - "src/main/claude/claude-structured-model-confirmation.test.ts": 332, - "src/main/claude/claude-structured-model-preflight.test.ts": 125, - "src/main/claude/claude-structured-option-confirmation.test.ts": 379, - "src/main/claude/claude-structured-options-context-window.test.ts": 452, - "src/main/claude/claude-structured-options.test.ts": 296, - "src/main/claude/claude-structured-owner-identity.test.ts": 64, - "src/main/claude/claude-structured-permission-mode.test.ts": 57, - "src/main/claude/claude-structured-prompt-items.test.ts": 157, - "src/main/claude/claude-structured-prompt-ownership.test.ts": 592, - "src/main/claude/claude-structured-provider-fallback.test.ts": 338, - "src/main/claude/claude-structured-real-cli.test.ts": 257, - "src/main/claude/claude-structured-resume-point.test.ts": 426, - "src/main/claude/claude-structured-session-acquisition-processless.test.ts": 471, - "src/main/claude/claude-structured-session-adapter-turns.test.ts": 333, - "src/main/claude/claude-structured-session-adapter.test.ts": 1001, - "src/main/claude/claude-structured-session-close.test.ts": 504, - "src/main/claude/claude-structured-session-commands.test.ts": 263, - "src/main/claude/claude-structured-session-reading-control.test.ts": 446, - "src/main/claude/claude-structured-session-recovery.test.ts": 282, - "src/main/claude/claude-structured-session-startup.test.ts": 309, - "src/main/claude/claude-structured-turn-resume-point.test.ts": 390, - "src/main/claude/claude-subagent-group-row.test.ts": 37, - "src/main/claude/claude-subagent-id-aliases.test.ts": 45, - "src/main/claude/claude-subagent-roster.test.ts": 105, - "src/main/claude/claude-subagent-task-frames.test.ts": 55, - "src/main/claude/claude-transcript-branch-graph.test.ts": 60, - "src/main/claude/claude-transcript-branch-streaming.test.ts": 224, - "src/main/claude/claude-transcript-entry-uuid.test.ts": 36, - "src/main/claude/claude-transcript-marker-branch-proof.test.ts": 86, - "src/main/claude/claude-transcript-rewind-proof.test.ts": 56, - "src/main/claude/claude-turn-outcome.test.ts": 121, - "src/main/claude/claude-turn-ownership.test.ts": 3385, - "src/main/claude/claude-turn-resumption.test.ts": 185, - "src/main/claude/compact-status-registration.test.ts": 175, - "src/main/claude/hook-service.test.ts": 401, - "src/main/claude/statusline-script.test.ts": 207, - "src/main/cli/appimage-extracted-root.test.ts": 198, - "src/main/cli/appimage-extraction-pruning.test.ts": 90, - "src/main/cli/appimage-payload-removal.reentrancy.test.ts": 49, - "src/main/cli/appimage-payload-removal.test.ts": 66, - "src/main/cli/appimage-registration-lock.test.ts": 57, - "src/main/cli/appimage-stable-launcher.test.ts": 231, - "src/main/cli/cli-command-installation-races.test.ts": 187, - "src/main/cli/cli-command-privileged-transaction.test.ts": 133, - "src/main/cli/cli-installer-appimage-ownership.test.ts": 206, - "src/main/cli/cli-installer-appimage-removal.test.ts": 281, - "src/main/cli/cli-installer-command-conflicts.test.ts": 230, - "src/main/cli/cli-installer-macos-command-path.test.ts": 250, - "src/main/cli/cli-installer-windows-path.test.ts": 148, - "src/main/cli/cli-installer.test.ts": 323, - "src/main/cli/cli-privileged-processes.test.ts": 51, - "src/main/cli/keyed-promise-queue.test.ts": 33, - "src/main/cli/legacy-appimage-cli-wrapper.test.ts": 57, - "src/main/cli/linux-bare-orca-dispatcher.test.ts": 173, - "src/main/cli/linux-terminal-orca-cli-shim.test.ts": 154, - "src/main/cli/native-cli-powershell-argv-boundary.test.ts": 54, - "src/main/cli/orca-cli-child-path.test.ts": 71, - "src/main/cli/packaged-cli-assets.test.ts": 863, - "src/main/cli/windows-launcher-asset.test.ts": 42, - "src/main/cli/windows-user-path-registry.test.ts": 63, + "src/main/browser/agent-browser-process-environment.test.ts": 53, + "src/main/browser/agent-browser-session-reset.test.ts": 60, + "src/main/browser/browser-certificate-trust-controller.test.ts": 436, + "src/main/browser/browser-client-download-relay.test.ts": 257, + "src/main/browser/browser-client-download-routing.test.ts": 239, + "src/main/browser/browser-client-file-channel-negotiation.test.ts": 307, + "src/main/browser/browser-client-file-channel-reconnect.test.ts": 536, + "src/main/browser/browser-client-host-attach-request.test.ts": 257, + "src/main/browser/browser-client-host-authority-replacement.test.ts": 250, + "src/main/browser/browser-client-host-command-dispatcher.test.ts": 268, + "src/main/browser/browser-client-host-command-retention.test.ts": 837, + "src/main/browser/browser-client-host-id.test.ts": 63, + "src/main/browser/browser-client-host-placement-preparation.test.ts": 95, + "src/main/browser/browser-client-host-published-url-wiring.test.ts": 537, + "src/main/browser/browser-client-host-reconciliation-command-order.test.ts": 257, + "src/main/browser/browser-client-network-route-registry.test.ts": 292, + "src/main/browser/browser-client-page-automation-runtime.test.ts": 61, + "src/main/browser/browser-client-page-command-executor-fencing.test.ts": 274, + "src/main/browser/browser-client-page-command-executor.test.ts": 311, + "src/main/browser/browser-client-page-command-integration.test.ts": 345, + "src/main/browser/browser-client-page-execution-host-supersession.test.ts": 414, + "src/main/browser/browser-client-page-inventory.test.ts": 232, + "src/main/browser/browser-client-page-metadata-transport.test.ts": 260, + "src/main/browser/browser-client-page-published-url.test.ts": 257, + "src/main/browser/browser-client-page-reconciliation-adapters.test.ts": 394, + "src/main/browser/browser-client-page-renderer-bridge.test.ts": 258, + "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts": 840, + "src/main/browser/browser-client-page-renderer-runtime.test.ts": 230, + "src/main/browser/browser-client-page-unavailability.test.ts": 316, + "src/main/browser/browser-client-page-upload-routing.test.ts": 366, + "src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts": 388, + "src/main/browser/browser-client-route-cookie-import.test.ts": 89, + "src/main/browser/browser-client-upload-buffer-retention.test.ts": 530, + "src/main/browser/browser-client-upload-command.test.ts": 261, + "src/main/browser/browser-client-upload-staging.test.ts": 311, + "src/main/browser/browser-client-upload-transfer.test.ts": 247, + "src/main/browser/browser-cookie-clear-preserve.test.ts": 136, + "src/main/browser/browser-cookie-clear-store-lifecycle.test.ts": 81, + "src/main/browser/browser-cookie-clear-store.test.ts": 83, + "src/main/browser/browser-cookie-decryption.test.ts": 67, + "src/main/browser/browser-cookie-import-clear-atomicity.test.ts": 75, + "src/main/browser/browser-cookie-import-concurrency.test.ts": 550, + "src/main/browser/browser-cookie-import-google-exclusion.test.ts": 472, + "src/main/browser/browser-cookie-import-partition-fidelity.test.ts": 603, + "src/main/browser/browser-cookie-import-partition-reland.electron.test.ts": 1241, + "src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts": 643, + "src/main/browser/browser-cookie-import-partition-success.electron.test.ts": 948, + "src/main/browser/browser-cookie-import-partition.electron.test.ts": 782, + "src/main/browser/browser-cookie-import-plan-writes.test.ts": 80, + "src/main/browser/browser-cookie-import-policy.test.ts": 202, + "src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts": 918, + "src/main/browser/browser-cookie-import-replacement.test.ts": 539, + "src/main/browser/browser-cookie-import-route-partition-staging.test.ts": 378, + "src/main/browser/browser-cookie-import-scope.test.ts": 403, + "src/main/browser/browser-cookie-import-snapshot-ownership.test.ts": 528, + "src/main/browser/browser-cookie-import-undecryptable.test.ts": 753, + "src/main/browser/browser-cookie-import-validated-partition.electron.test.ts": 723, + "src/main/browser/browser-cookie-import-write.test.ts": 75, + "src/main/browser/browser-cookie-import.comet.test.ts": 939, + "src/main/browser/browser-cookie-import.helium.test.ts": 892, + "src/main/browser/browser-cookie-import.test.ts": 1375, + "src/main/browser/browser-cookie-public-suffix-scope.test.ts": 71, + "src/main/browser/browser-cookie-registrable-family.test.ts": 77, + "src/main/browser/browser-cookie-samesite.electron.test.ts": 796, + "src/main/browser/browser-cookie-source-partition.test.ts": 67, + "src/main/browser/browser-cookie-validation.test.ts": 79, + "src/main/browser/browser-download-destination.test.ts": 72, + "src/main/browser/browser-execution-host-storage-identity.test.ts": 256, + "src/main/browser/browser-google-auth-ua.test.ts": 52, + "src/main/browser/browser-grab-payload.test.ts": 64, + "src/main/browser/browser-guest-shortcut-forwarding.test.ts": 346, + "src/main/browser/browser-guest-wheel-zoom-scroll.test.ts": 54, + "src/main/browser/browser-host-client-identity.test.ts": 81, + "src/main/browser/browser-host-lease-reconnect-delay.test.ts": 45, + "src/main/browser/browser-identity-mode-record.test.ts": 52, + "src/main/browser/browser-identity-mode-store.test.ts": 140, + "src/main/browser/browser-manager-annotation-bridge.test.ts": 382, + "src/main/browser/browser-manager-auth-user-agent.test.ts": 378, + "src/main/browser/browser-manager-client-hosted-downloads.test.ts": 467, + "src/main/browser/browser-manager-destroyed-guest-downloads.test.ts": 499, + "src/main/browser/browser-manager-destroyed-guest.test.ts": 424, + "src/main/browser/browser-manager-downloads.test.ts": 391, + "src/main/browser/browser-manager-grab-capture.test.ts": 365, + "src/main/browser/browser-manager-grab-mode.test.ts": 397, + "src/main/browser/browser-manager-grab-selection.test.ts": 446, + "src/main/browser/browser-manager-grab-shortcuts.test.ts": 413, + "src/main/browser/browser-manager-guest-lifecycle.test.ts": 356, + "src/main/browser/browser-manager-guest-policy-profile.test.ts": 394, + "src/main/browser/browser-manager-guest-shortcuts.test.ts": 388, + "src/main/browser/browser-manager-guest-visibility.test.ts": 398, + "src/main/browser/browser-manager-load-failure-replay.test.ts": 412, + "src/main/browser/browser-manager-popup-child.test.ts": 442, + "src/main/browser/browser-manager-popup-routing.test.ts": 398, + "src/main/browser/browser-manager-tab-identity.test.ts": 387, + "src/main/browser/browser-manager-viewport-crashed-guest.test.ts": 322, + "src/main/browser/browser-manager-viewport-override.test.ts": 422, + "src/main/browser/browser-manager-viewport-ownership.test.ts": 409, + "src/main/browser/browser-manager-viewport-partial-failure.test.ts": 372, + "src/main/browser/browser-manager-viewport-touch-reset.test.ts": 317, + "src/main/browser/browser-manager-worker-request-user-agent.test.ts": 376, + "src/main/browser/browser-network-deferred-socket.test.ts": 45, + "src/main/browser/browser-network-execution-route.test.ts": 236, + "src/main/browser/browser-network-tunnel-client-memory-budget.test.ts": 93, + "src/main/browser/browser-network-tunnel-client.test.ts": 129, + "src/main/browser/browser-network-tunnel-conformance.test.ts": 169, + "src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts": 52, + "src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts": 68, + "src/main/browser/browser-network-tunnel-session-stream-scoped-failure.test.ts": 103, + "src/main/browser/browser-network-tunnel-session.test.ts": 140, + "src/main/browser/browser-page-initiated-tab-budget.test.ts": 45, + "src/main/browser/browser-popup-new-tab-intent.test.ts": 38, + "src/main/browser/browser-process-user-agent.test.ts": 55, + "src/main/browser/browser-route-dns-prefetch.electron.test.ts": 4477, + "src/main/browser/browser-route-guest-popups.test.ts": 348, + "src/main/browser/browser-route-h3-egress.electron.test.ts": 18009, + "src/main/browser/browser-route-identity.test.ts": 63, + "src/main/browser/browser-route-partition-binding-capacity.test.ts": 84, + "src/main/browser/browser-route-partition-binding-store.test.ts": 122, + "src/main/browser/browser-route-partition-migration.test.ts": 105, + "src/main/browser/browser-route-partition-stability.test.ts": 292, + "src/main/browser/browser-route-partition-storage-lifecycle.test.ts": 116, + "src/main/browser/browser-route-partition-storage-retirement.test.ts": 270, + "src/main/browser/browser-route-partition-storage-runtime.test.ts": 58, + "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts": 3825, + "src/main/browser/browser-route-prepared-page-rekey.test.ts": 51, + "src/main/browser/browser-route-session-registry.test.ts": 111, + "src/main/browser/browser-route-tcp-egress.electron.test.ts": 2122, + "src/main/browser/browser-route-webcontents-registry.test.ts": 428, + "src/main/browser/browser-route-webrtc-egress.electron.test.ts": 13343, + "src/main/browser/browser-screencast-dialog-settlement.test.ts": 115, + "src/main/browser/browser-screencast-first-frame-timeout.test.ts": 93, + "src/main/browser/browser-screencast-lifecycle.test.ts": 104, + "src/main/browser/browser-screencast-live-frame-allocation.test.ts": 100, + "src/main/browser/browser-screencast-snapshot-scaling.test.ts": 202, + "src/main/browser/browser-screencast-stream.test.ts": 639, + "src/main/browser/browser-session-cookie-staging.scoped.test.ts": 475, + "src/main/browser/browser-session-cookie-staging.test.ts": 316, + "src/main/browser/browser-session-partition-policies.test.ts": 95, + "src/main/browser/browser-session-partition-proxy-install.test.ts": 499, + "src/main/browser/browser-session-proxy.test.ts": 223, + "src/main/browser/browser-session-registry-identity.persistence.test.ts": 504, + "src/main/browser/browser-session-registry.persistence.test.ts": 887, + "src/main/browser/browser-session-registry.test.ts": 839, + "src/main/browser/browser-session-startup.test.ts": 71, + "src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts": 645, + "src/main/browser/browser-session-ua-wire-identity-cross-context.electron.test.ts": 5588, + "src/main/browser/browser-session-ua-wire-identity.electron.test.ts": 6689, + "src/main/browser/browser-session-user-agent-migration-inspection.test.ts": 249, + "src/main/browser/browser-tab-identity.test.ts": 44, + "src/main/browser/browser-text-insertion.test.ts": 72, + "src/main/browser/browser-webauthn-access.test.ts": 55, + "src/main/browser/browser-webauthn-account-picker.test.ts": 70, + "src/main/browser/browser-webauthn-profile-delete.test.ts": 474, + "src/main/browser/cdp-bridge-integration.test.ts": 6649, + "src/main/browser/cdp-bridge-state.test.ts": 73, + "src/main/browser/cdp-client-response-writer.test.ts": 319, + "src/main/browser/cdp-keyboard-us-layout.test.ts": 73, + "src/main/browser/cdp-print-to-pdf.test.ts": 64, + "src/main/browser/cdp-screenshot.test.ts": 89, + "src/main/browser/cdp-ws-proxy-focus-replay.test.ts": 293, + "src/main/browser/cdp-ws-proxy.test.ts": 2808, + "src/main/browser/chromium-cookie-snapshot.test.ts": 521, + "src/main/browser/client-route-cookie-import-source-store.test.ts": 114, + "src/main/browser/doc-preview-download-block-notice.test.ts": 65, + "src/main/browser/doc-preview-failure-notice.test.ts": 47, + "src/main/browser/doc-preview-file-reader.test.ts": 106, + "src/main/browser/doc-preview-grant-registry.test.ts": 74, + "src/main/browser/doc-preview-guest-policy.test.ts": 255, + "src/main/browser/doc-preview-protocol.test.ts": 86, + "src/main/browser/doc-preview-response-allocation.test.ts": 155, + "src/main/browser/electron-debugger-lease.test.ts": 46, + "src/main/browser/electron-probe-display-launch.test.ts": 44, + "src/main/browser/execution-route-socket-duplex.test.ts": 91, + "src/main/browser/grab-guest-script.test.ts": 91, + "src/main/browser/guest-cdp-command.test.ts": 41, + "src/main/browser/local-ssh-browser-partition-identity.test.ts": 466, + "src/main/browser/local-ssh-browser-partitions.probe-cache.test.ts": 56, + "src/main/browser/local-ssh-browser-route-target.test.ts": 50, + "src/main/browser/local-ssh-browser-route.test.ts": 84, + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts": 362, + "src/main/browser/paired-runtime-browser-client-host-composition.test.ts": 261, + "src/main/browser/paired-runtime-browser-client-host-identity-wiring.test.ts": 995, + "src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts": 510, + "src/main/browser/paired-runtime-browser-client-host-registry.test.ts": 79, + "src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts": 574, + "src/main/browser/paired-runtime-browser-client-host-runtime.test.ts": 576, + "src/main/browser/paired-runtime-browser-client-host.test.ts": 414, + "src/main/browser/paired-runtime-browser-host-command-admission.test.ts": 266, + "src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts": 967, + "src/main/browser/paired-runtime-browser-host-lease.test.ts": 943, + "src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts": 338, + "src/main/browser/paired-runtime-browser-network-route.test.ts": 1296, + "src/main/browser/popup-origin-bar-window.test.ts": 106, + "src/main/browser/remote-browser-socks-buffering.test.ts": 372, + "src/main/browser/remote-browser-socks-server.test.ts": 309, + "src/main/browser/snapshot-engine-iframe-sessions.test.ts": 96, + "src/main/browser/snapshot-engine.test.ts": 60, + "src/main/browser/ssh-browser-network-execution-route.test.ts": 554, + "src/main/browser/system-ssh-socks-client-socket.test.ts": 71, + "src/main/browser/wsl-browser-network-execution-route.test.ts": 354, + "src/main/browser/wsl-browser-network-relay-launch.test.ts": 83, + "src/main/claude-accounts/claude-account-folder.test.ts": 275, + "src/main/claude-accounts/claude-account-service.test.ts": 792, + "src/main/claude-accounts/claude-command-process.test.ts": 81, + "src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts": 608, + "src/main/claude-accounts/claude-duplicate-account.test.ts": 52, + "src/main/claude-accounts/claude-launch-routing.test.ts": 540, + "src/main/claude-accounts/claude-profile-history-windows.test.ts": 132, + "src/main/claude-accounts/claude-profile-history.test.ts": 107, + "src/main/claude-accounts/claude-profile-paths.test.ts": 80, + "src/main/claude-accounts/claude-profile-provisioning.test.ts": 762, + "src/main/claude-accounts/claude-profile-router.test.ts": 7997, + "src/main/claude-accounts/claude-profile-setup-worker.test.ts": 240, + "src/main/claude-accounts/claude-profile-setup.test.ts": 181, + "src/main/claude-accounts/claude-profile-shell-composition.test.ts": 72, + "src/main/claude-accounts/claude-profile-wsl-router.test.ts": 787, + "src/main/claude-accounts/claude-sign-in-browser.test.ts": 319, + "src/main/claude-accounts/claude-structured-auth-policy.test.ts": 61, + "src/main/claude-accounts/keychain-config-directory-aliases.test.ts": 57, + "src/main/claude-accounts/keychain.test.ts": 56, + "src/main/claude-accounts/runtime-auth-service.test.ts": 297, + "src/main/claude-accounts/runtime-paths.test.ts": 87, + "src/main/claude-accounts/runtime-selection.test.ts": 69, + "src/main/claude-accounts/windows-command-invocation.test.ts": 44, + "src/main/claude-usage/claude-model-pricing.test.ts": 68, + "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 47, + "src/main/claude-usage/persisted-source-authority.test.ts": 320, + "src/main/claude-usage/persisted-token-columns-store.test.ts": 467, + "src/main/claude-usage/persisted-token-columns.test.ts": 91, + "src/main/claude-usage/scanner-incremental-append.test.ts": 191, + "src/main/claude-usage/scanner-incremental-ownership.test.ts": 414, + "src/main/claude-usage/scanner-incremental-reader.test.ts": 666, + "src/main/claude-usage/scanner-large-directory.test.ts": 285, + "src/main/claude-usage/scanner-scan.test.ts": 242, + "src/main/claude-usage/scanner.test.ts": 91, + "src/main/claude-usage/source-cache-compression.test.ts": 89, + "src/main/claude-usage/source-cache-write-retention.test.ts": 305, + "src/main/claude-usage/store-checkpoint-persistence.test.ts": 590, + "src/main/claude-usage/store-source-authority.test.ts": 557, + "src/main/claude-usage/store.test.ts": 403, + "src/main/claude-usage/transcript-file-discovery-errors.test.ts": 60, + "src/main/claude-usage/transcript-projection-integrity.test.ts": 207, + "src/main/claude-usage/transcript-reader-lifetime.test.ts": 66, + "src/main/claude-usage/transcript-record-parser-prefilter.test.ts": 57, + "src/main/claude-usage/transcript-tuple-append.test.ts": 270, + "src/main/claude-usage/worktree-attribution-scaling.test.ts": 60, + "src/main/claude/claude-adapter-capture-replay.test.ts": 669, + "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 3228, + "src/main/claude/claude-agent-sdk-control-requests.test.ts": 54, + "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 113, + "src/main/claude/claude-agent-sdk-exit-proof.test.ts": 8144, + "src/main/claude/claude-agent-sdk-import-boundary.test.ts": 1579, + "src/main/claude/claude-agent-sdk-process-spawn.test.ts": 180, + "src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 130, + "src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 151, + "src/main/claude/claude-api-retry-idle-sweep.test.ts": 1549, + "src/main/claude/claude-api-retry-row.test.ts": 651, + "src/main/claude/claude-background-stop-records.test.ts": 828, + "src/main/claude/claude-background-task-resume.test.ts": 70, + "src/main/claude/claude-background-task-retention.test.ts": 476, + "src/main/claude/claude-background-task-row-journal.test.ts": 113, + "src/main/claude/claude-background-task-rows.test.ts": 166, + "src/main/claude/claude-background-task-terminal-frames.test.ts": 94, + "src/main/claude/claude-background-task-tracker.test.ts": 155, + "src/main/claude/claude-background-task-turn-resumption.test.ts": 376, + "src/main/claude/claude-background-wake-retention.test.ts": 1040, + "src/main/claude/claude-child-exit-proof-ladder.test.ts": 5645, + "src/main/claude/claude-child-process-environment.test.ts": 65, + "src/main/claude/claude-child-work-decoder.test.ts": 85, + "src/main/claude/claude-child-work-evidence-retention.test.ts": 483, + "src/main/claude/claude-cli-flag-prewarm.test.ts": 246, + "src/main/claude/claude-cli-flag-support.test.ts": 399, + "src/main/claude/claude-close-turn-end.test.ts": 554, + "src/main/claude/claude-command-lifecycle-frames.test.ts": 378, + "src/main/claude/claude-command-lifecycle.test.ts": 636, + "src/main/claude/claude-command-turn.test.ts": 328, + "src/main/claude/claude-config-dir-pin.test.ts": 65, + "src/main/claude/claude-context-model-switch.test.ts": 435, + "src/main/claude/claude-context-usage-restart.test.ts": 832, + "src/main/claude/claude-context-usage-unloaded-turn.test.ts": 3083, + "src/main/claude/claude-context-usage.test.ts": 298, + "src/main/claude/claude-context-window-hint.test.ts": 473, + "src/main/claude/claude-descendant-escalation-boundary.test.ts": 564, + "src/main/claude/claude-fold-steer-receipt.test.ts": 684, + "src/main/claude/claude-folder-trust-file-wsl-guest.test.ts": 60, + "src/main/claude/claude-folder-trust-file.test.ts": 737, + "src/main/claude/claude-history-window-source-budget.test.ts": 594, + "src/main/claude/claude-hook-event-install.test.ts": 142, + "src/main/claude/claude-hook-event-versions.test.ts": 87, + "src/main/claude/claude-informational-row.test.ts": 615, + "src/main/claude/claude-journal-turn-scope.test.ts": 286, + "src/main/claude/claude-model-catalog-probe.test.ts": 2112, + "src/main/claude/claude-profile-hooks.test.ts": 193, + "src/main/claude/claude-provisional-row-corrections.test.ts": 61, + "src/main/claude/claude-request-end-wait.test.ts": 49, + "src/main/claude/claude-settings-schema-install.test.ts": 181, + "src/main/claude/claude-slash-command-catalog.test.ts": 59, + "src/main/claude/claude-status-line-install.test.ts": 124, + "src/main/claude/claude-stop-ack-race.test.ts": 1348, + "src/main/claude/claude-stream-json-connection-close.test.ts": 653, + "src/main/claude/claude-stream-json-connection.test.ts": 10922, + "src/main/claude/claude-streamed-text-checkpoints.test.ts": 64, + "src/main/claude/claude-structured-applied-options.test.ts": 631, + "src/main/claude/claude-structured-auth-parity.test.ts": 458, + "src/main/claude/claude-structured-child-work-captures.test.ts": 1096, + "src/main/claude/claude-structured-child-work-producer.test.ts": 1124, + "src/main/claude/claude-structured-configured-default.test.ts": 833, + "src/main/claude/claude-structured-content-parts.test.ts": 352, + "src/main/claude/claude-structured-control-actions.test.ts": 393, + "src/main/claude/claude-structured-conversation-stop.test.ts": 562, + "src/main/claude/claude-structured-dispatch-admission.test.ts": 567, + "src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts": 291, + "src/main/claude/claude-structured-dispatch-boundary.test.ts": 622, + "src/main/claude/claude-structured-dispatch-content.test.ts": 270, + "src/main/claude/claude-structured-dispatch-queue-boundary.test.ts": 659, + "src/main/claude/claude-structured-dispatch.test.ts": 1937, + "src/main/claude/claude-structured-effort-default-at-rest.test.ts": 714, + "src/main/claude/claude-structured-effort-reporting.test.ts": 715, + "src/main/claude/claude-structured-history-window.test.ts": 378, + "src/main/claude/claude-structured-in-turn-stop.test.ts": 607, + "src/main/claude/claude-structured-inbound-control.test.ts": 75, + "src/main/claude/claude-structured-journal-prompt-cards.test.ts": 60, + "src/main/claude/claude-structured-journal-prompt-retry.test.ts": 332, + "src/main/claude/claude-structured-journal-translation-background-tasks.test.ts": 506, + "src/main/claude/claude-structured-journal-translation-context-usage.test.ts": 456, + "src/main/claude/claude-structured-journal-translation-subagents.test.ts": 400, + "src/main/claude/claude-structured-journal-translation-turn-timing.test.ts": 730, + "src/main/claude/claude-structured-journal-translation.plan.test.ts": 328, + "src/main/claude/claude-structured-journal-translation.test.ts": 726, + "src/main/claude/claude-structured-launch-account-routing.test.ts": 631, + "src/main/claude/claude-structured-launch-args.test.ts": 40, + "src/main/claude/claude-structured-launch-resolution.test.ts": 647, + "src/main/claude/claude-structured-launch-visuals.test.ts": 477, + "src/main/claude/claude-structured-live-provider-process.test.ts": 587, + "src/main/claude/claude-structured-model-confirmation.test.ts": 673, + "src/main/claude/claude-structured-model-preflight.test.ts": 343, + "src/main/claude/claude-structured-option-confirmation.test.ts": 758, + "src/main/claude/claude-structured-options-context-window.test.ts": 414, + "src/main/claude/claude-structured-options.test.ts": 431, + "src/main/claude/claude-structured-owner-identity.test.ts": 90, + "src/main/claude/claude-structured-permission-mode.test.ts": 58, + "src/main/claude/claude-structured-prompt-items.test.ts": 316, + "src/main/claude/claude-structured-prompt-ownership.test.ts": 700, + "src/main/claude/claude-structured-provider-fallback.test.ts": 550, + "src/main/claude/claude-structured-queued-stop.test.ts": 538, + "src/main/claude/claude-structured-rate-limit-retry.test.ts": 264, + "src/main/claude/claude-structured-real-cli-fold.test.ts": 490, + "src/main/claude/claude-structured-real-cli-visuals.test.ts": 584, + "src/main/claude/claude-structured-real-cli.test.ts": 787, + "src/main/claude/claude-structured-reasoning-lifecycle.test.ts": 350, + "src/main/claude/claude-structured-reasoning.test.ts": 289, + "src/main/claude/claude-structured-requested-stop.test.ts": 557, + "src/main/claude/claude-structured-resume-point.test.ts": 758, + "src/main/claude/claude-structured-retired-model.test.ts": 531, + "src/main/claude/claude-structured-session-acquisition-processless.test.ts": 679, + "src/main/claude/claude-structured-session-adapter-turns.test.ts": 726, + "src/main/claude/claude-structured-session-adapter.test.ts": 824, + "src/main/claude/claude-structured-session-close.test.ts": 635, + "src/main/claude/claude-structured-session-commands.test.ts": 694, + "src/main/claude/claude-structured-session-exit-blame.test.ts": 672, + "src/main/claude/claude-structured-session-reading-control.test.ts": 886, + "src/main/claude/claude-structured-session-recovery.test.ts": 641, + "src/main/claude/claude-structured-session-startup.test.ts": 723, + "src/main/claude/claude-structured-sign-in.test.ts": 317, + "src/main/claude/claude-structured-spawn-options.test.ts": 442, + "src/main/claude/claude-structured-start-fast-mode.test.ts": 554, + "src/main/claude/claude-structured-turn-resume-point.test.ts": 695, + "src/main/claude/claude-subagent-group-row.test.ts": 55, + "src/main/claude/claude-subagent-id-aliases.test.ts": 53, + "src/main/claude/claude-subagent-journaled-roster.test.ts": 55, + "src/main/claude/claude-subagent-permission-request.test.ts": 2224, + "src/main/claude/claude-subagent-resume-after-restart.test.ts": 798, + "src/main/claude/claude-subagent-roster-groups.test.ts": 70, + "src/main/claude/claude-subagent-roster-retention.test.ts": 116, + "src/main/claude/claude-subagent-roster.test.ts": 371, + "src/main/claude/claude-subagent-task-frames.test.ts": 83, + "src/main/claude/claude-supervised-stop-real-cli.test.ts": 450, + "src/main/claude/claude-supervised-stop.integration.test.ts": 3745, + "src/main/claude/claude-transcript-branch-graph.test.ts": 46, + "src/main/claude/claude-transcript-branch-streaming.test.ts": 162, + "src/main/claude/claude-transcript-entry-uuid.test.ts": 45, + "src/main/claude/claude-transcript-marker-branch-proof.test.ts": 82, + "src/main/claude/claude-transcript-rewind-proof.test.ts": 57, + "src/main/claude/claude-turn-outcome.test.ts": 450, + "src/main/claude/claude-turn-ownership.test.ts": 743, + "src/main/claude/claude-turn-resumption.test.ts": 453, + "src/main/claude/compact-status-registration.test.ts": 220, + "src/main/claude/hook-service.test.ts": 206, + "src/main/claude/statusline-script.test.ts": 179, + "src/main/claude/windows-hook-files.test.ts": 151, + "src/main/cli/appimage-extracted-root.test.ts": 139, + "src/main/cli/appimage-extraction-pruning.test.ts": 102, + "src/main/cli/appimage-payload-removal.reentrancy.test.ts": 51, + "src/main/cli/appimage-payload-removal.test.ts": 62, + "src/main/cli/appimage-registration-lock.test.ts": 71, + "src/main/cli/appimage-stable-launcher.test.ts": 221, + "src/main/cli/cli-command-installation-races.test.ts": 152, + "src/main/cli/cli-command-privileged-transaction.test.ts": 112, + "src/main/cli/cli-installer-appimage-ownership.test.ts": 174, + "src/main/cli/cli-installer-appimage-removal.test.ts": 290, + "src/main/cli/cli-installer-command-conflicts.test.ts": 246, + "src/main/cli/cli-installer-macos-command-path.test.ts": 190, + "src/main/cli/cli-installer-windows-path.test.ts": 178, + "src/main/cli/cli-installer.test.ts": 258, + "src/main/cli/cli-privileged-processes.test.ts": 54, + "src/main/cli/keyed-promise-queue.test.ts": 55, + "src/main/cli/legacy-appimage-cli-wrapper.test.ts": 53, + "src/main/cli/linux-bare-orca-dispatcher.test.ts": 144, + "src/main/cli/linux-terminal-orca-cli-shim.test.ts": 148, + "src/main/cli/native-cli-powershell-argv-boundary.test.ts": 51, + "src/main/cli/orca-cli-child-path.test.ts": 82, + "src/main/cli/packaged-cli-assets.test.ts": 6578, + "src/main/cli/windows-user-path-registry.test.ts": 53, "src/main/cli/wsl-cli-installer.test.ts": 298, - "src/main/cli/wsl-cli-powershell-boundary.test.ts": 104, - "src/main/cli/wsl-cli-registration-operation.test.ts": 90, - "src/main/cli/wsl-cli-registration-reconciliation.test.ts": 264, - "src/main/cli/wsl-cli-registration-registry.test.ts": 174, - "src/main/cli/wsl-managed-cli.test.ts": 51, - "src/main/cli/wsl-managed-cli.wsl.test.ts": 93, - "src/main/codex-accounts/async-file-rename.test.ts": 1055, - "src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts": 49, - "src/main/codex-accounts/codex-auth-workspace-identity.test.ts": 57, - "src/main/codex-accounts/codex-config-mirror.test.ts": 218, - "src/main/codex-accounts/codex-credential-absence-grace.test.ts": 138, - "src/main/codex-accounts/codex-login-auth-url.test.ts": 49, - "src/main/codex-accounts/codex-reset-credit-ledger.test.ts": 274, - "src/main/codex-accounts/codex-windows-interactive-login.test.ts": 343, - "src/main/codex-accounts/fs-utils.test.ts": 174, - "src/main/codex-accounts/host-codex-managed-home-ownership.test.ts": 40, - "src/main/codex-accounts/legacy-shared-auth-migration.test.ts": 121, - "src/main/codex-accounts/legacy-shared-config-compatibility.test.ts": 124, - "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts": 17191, - "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts": 622, - "src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts": 77, - "src/main/codex-accounts/managed-codex-auth-readiness.test.ts": 76, - "src/main/codex-accounts/runtime-home-legacy-migration.test.ts": 608, - "src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts": 326, - "src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts": 224, - "src/main/codex-accounts/runtime-home-per-account-homes.test.ts": 722, - "src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts": 848, - "src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts": 445, - "src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts": 295, - "src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts": 1201, - "src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts": 482, - "src/main/codex-accounts/runtime-home-session-migration-pass.test.ts": 408, - "src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts": 1006, - "src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts": 740, - "src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts": 490, - "src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts": 136, - "src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts": 1054, - "src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts": 890, - "src/main/codex-accounts/runtime-home-wsl-system-default.test.ts": 503, - "src/main/codex-accounts/runtime-selection.test.ts": 73, - "src/main/codex-accounts/service-abandoned-login-supersede.test.ts": 861, - "src/main/codex-accounts/service-account-add-login.test.ts": 509, - "src/main/codex-accounts/service-account-selection-and-removal.test.ts": 846, - "src/main/codex-accounts/service-add-account-from-home.test.ts": 444, - "src/main/codex-accounts/service-login-process-teardown.test.ts": 426, - "src/main/codex-accounts/service-managed-home-removal-retries.test.ts": 186, - "src/main/codex-accounts/service-quota-refresh-decoupling.test.ts": 280, - "src/main/codex-accounts/service-reauthenticate-activation.test.ts": 268, - "src/main/codex-accounts/service-reset-credit-durability.test.ts": 703, - "src/main/codex-accounts/service-reset-credit-home-ownership.test.ts": 473, - "src/main/codex-accounts/service-reset-credit-target-routing.test.ts": 580, - "src/main/codex-accounts/service-system-default-identity.test.ts": 490, - "src/main/codex-accounts/service-wsl-accounts.test.ts": 660, - "src/main/codex-accounts/service.test.ts": 260, - "src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts": 409, - "src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts": 292, - "src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts": 392, - "src/main/codex-accounts/wsl-codex-command.test.ts": 40, - "src/main/codex-cli/codex-home-process-lock.test.ts": 54, - "src/main/codex-cli/command.test.ts": 88, - "src/main/codex-usage/codex-rollout-resume-state.test.ts": 67, - "src/main/codex-usage/codex-usage-cost-estimate.test.ts": 46, - "src/main/codex-usage/codex-usage-long-context-billing.test.ts": 242, - "src/main/codex-usage/scanner-attribution-memo.test.ts": 170, - "src/main/codex-usage/scanner-file-identity.test.ts": 211, - "src/main/codex-usage/scanner-incremental-append.test.ts": 1002, - "src/main/codex-usage/scanner-large-directory.test.ts": 1168, - "src/main/codex-usage/scanner-paths.test.ts": 340, - "src/main/codex-usage/scanner.test.ts": 158, - "src/main/codex-usage/store-automation-usage.test.ts": 117, - "src/main/codex-usage/store-model-pricing.test.ts": 215, - "src/main/codex-usage/store-orca-scope.test.ts": 185, - "src/main/codex-usage/store-persistence.test.ts": 125, - "src/main/codex-usage/store-snapshot.benchmark.test.ts": 310, - "src/main/codex/codex-account-session-bridge.test.ts": 77, - "src/main/codex/codex-ai-vault-session-resume.test.ts": 127, - "src/main/codex/codex-app-server-capability-cache.test.ts": 64, - "src/main/codex/codex-app-server-client.test.ts": 1502, - "src/main/codex/codex-app-server-connection.test.ts": 5199, - "src/main/provider-process/provider-process-supervisor.test.ts": 38, - "src/main/provider-process/provider-process-teardown.test.ts": 71, - "src/main/codex/codex-app-server-session.test.ts": 129, - "src/main/codex/codex-app-server-teardown.integration.test.ts": 21677, - "src/main/codex/codex-background-command-tracker.test.ts": 221, - "src/main/codex/codex-background-task-tracker.test.ts": 163, - "src/main/codex/codex-config-mirror-mcp-ownership.test.ts": 138, - "src/main/codex/codex-config-mirror.test.ts": 181, - "src/main/codex/codex-config-settings-removal.test.ts": 49, - "src/main/codex/codex-configured-launch-defaults.test.ts": 30, - "src/main/codex/codex-daemon-socket-path-guard.test.ts": 153, - "src/main/codex/codex-goal-journal-rows.test.ts": 90, - "src/main/codex/codex-home-paths.test.ts": 76, - "src/main/codex/codex-hook-legacy-profile-block.test.ts": 132, - "src/main/codex/codex-hook-trust-grant.test.ts": 1397, - "src/main/codex/codex-hooks-read-denial-followup.test.ts": 179, - "src/main/codex/codex-index-heal-binary-contract.test.ts": 49, - "src/main/codex/codex-legacy-session-resume.test.ts": 208, - "src/main/codex/codex-model-catalog-probe.test.ts": 3179, - "src/main/codex/codex-model-provider-config.test.ts": 32, - "src/main/codex/codex-notice-item-translation.test.ts": 226, - "src/main/codex/codex-pane-account-registry-mutations.test.ts": 50, - "src/main/codex/codex-pane-launch-account.test.ts": 64, - "src/main/codex/codex-persistent-command-retention.test.ts": 276, - "src/main/codex/codex-pinned-rollout-proof.test.ts": 53, - "src/main/codex/codex-prompt-registry-retention.test.ts": 376, - "src/main/codex/codex-real-home-path.test.ts": 57, - "src/main/codex/codex-requested-close-turn-timing.test.ts": 134, - "src/main/codex/codex-rollout-session-meta.test.ts": 81, - "src/main/codex/codex-server-request-disposition.test.ts": 112, - "src/main/codex/codex-session-backfill-marker.test.ts": 89, - "src/main/codex/codex-session-backfill-scan-dates.test.ts": 83, - "src/main/codex/codex-session-backfill.test.ts": 647, - "src/main/codex/codex-session-bridge.test.ts": 112, - "src/main/codex/codex-session-index-heal-state.test.ts": 345, - "src/main/codex/codex-session-index-heal.test.ts": 1408, - "src/main/codex/codex-session-migration-recent-exits.test.ts": 61, - "src/main/codex/codex-session-migration-scheduler.test.ts": 401, - "src/main/codex/codex-session-resume-home.test.ts": 66, - "src/main/codex/codex-session-resume-preparation.test.ts": 65, - "src/main/codex/codex-session-resume-windows.test.ts": 59, - "src/main/codex/codex-session-resume-wrong-account.test.ts": 60, - "src/main/codex/codex-session-source-home.test.ts": 30, - "src/main/codex/codex-stale-pane-accounts.test.ts": 130, - "src/main/codex/codex-state-db-backfill-recovery.test.ts": 307, - "src/main/codex/codex-state-db.test.ts": 70, - "src/main/codex/codex-structured-acquisition-exit-proof.test.ts": 417, - "src/main/codex/codex-structured-child-environment.test.ts": 134, - "src/main/codex/codex-structured-dispatch-admission.test.ts": 507, - "src/main/codex/codex-structured-dispatch-boundary.test.ts": 414, - "src/main/codex/codex-structured-dispatch-echo.test.ts": 57, - "src/main/codex/codex-structured-service-tier.test.ts": 320, - "src/main/codex/codex-structured-item-translation.test.ts": 161, - "src/main/codex/codex-structured-journal-compactions.test.ts": 267, - "src/main/codex/codex-structured-journal-goal-admission.test.ts": 258, - "src/main/codex/codex-structured-journal-goal-resume.test.ts": 327, - "src/main/codex/codex-structured-journal-goal-revision.test.ts": 242, - "src/main/codex/codex-structured-journal-goal-rows.test.ts": 107, - "src/main/codex/codex-structured-journal-subagent-readers.test.ts": 292, - "src/main/codex/codex-structured-journal-translation-settlement.test.ts": 1118, - "src/main/codex/codex-structured-journal-translation-streams.test.ts": 275, - "src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts": 245, - "src/main/codex/codex-structured-journal-translation-subagents.test.ts": 186, - "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts": 398, - "src/main/codex/codex-structured-journal-translation-turn-state.test.ts": 35, - "src/main/codex/codex-structured-journal-translation.test.ts": 282, - "src/main/codex/codex-structured-launch-resolution.test.ts": 102, - "src/main/codex/codex-structured-location-support.test.ts": 46, - "src/main/codex/codex-structured-owner-identity.test.ts": 45, - "src/main/codex/codex-structured-permission-policy.test.ts": 63, - "src/main/codex/codex-structured-prompt-items.test.ts": 224, - "src/main/codex/codex-structured-prompt-ownership.test.ts": 512, - "src/main/codex/codex-structured-prompt-replies.test.ts": 165, - "src/main/codex/codex-structured-rewind.test.ts": 306, - "src/main/codex/codex-structured-session-adapter-lifecycle.test.ts": 367, - "src/main/codex/codex-structured-session-adapter.test.ts": 318, - "src/main/codex/codex-structured-session-background-tasks.test.ts": 502, - "src/main/codex/codex-structured-session-cancel.test.ts": 456, - "src/main/codex/codex-structured-session-close.test.ts": 420, - "src/main/codex/codex-structured-session-options-catalog.test.ts": 246, - "src/main/codex/codex-structured-session-options.test.ts": 356, - "src/main/codex/codex-structured-session-shutdown.test.ts": 471, - "src/main/codex/codex-structured-thread-goal.test.ts": 73, - "src/main/codex/codex-structured-thread-open.test.ts": 300, - "src/main/codex/codex-subagent-execution-projection.test.ts": 202, - "src/main/codex/codex-subagent-executions.test.ts": 71, - "src/main/codex/codex-subagent-roster.test.ts": 118, - "src/main/codex/codex-tool-identity-translation.test.ts": 117, - "src/main/codex/codex-trust-config-concurrent-launch.test.ts": 225, - "src/main/codex/codex-trust-config-mutation-queue.test.ts": 45, - "src/main/codex/codex-trust-config-rollback.test.ts": 76, - "src/main/codex/codex-trust-grant-host.test.ts": 94, - "src/main/codex/codex-trust-grant-ledger.test.ts": 54, - "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, - "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/codex/codex-turn-ordinals.test.ts": 96, - "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, - "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, - "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, - "src/main/codex/config-plugin-registration-promotion.test.ts": 370, - "src/main/codex/config-settings-baseline-upgrade.test.ts": 104, - "src/main/codex/config-settings-promotion.test.ts": 508, - "src/main/codex/config-sync-stall.test.ts": 161, - "src/main/codex/config-toml-hook-trust-scaling.test.ts": 126, - "src/main/codex/config-toml-mcp-servers.test.ts": 37, - "src/main/codex/config-toml-trust-api-parity.test.ts": 91, - "src/main/codex/config-toml-trust-hash.test.ts": 44, - "src/main/codex/config-toml-trust-hook-read.test.ts": 125, - "src/main/codex/config-toml-trust-hook-removal.test.ts": 108, - "src/main/codex/config-toml-trust-hook-upsert.test.ts": 163, - "src/main/codex/config-toml-trust-key.test.ts": 54, - "src/main/codex/config-toml-trust-paths.test.ts": 86, - "src/main/codex/config-toml-trust-project.test.ts": 105, - "src/main/codex/hook-service-concurrent-launch-install.test.ts": 749, - "src/main/codex/hook-service-legacy-cleanup.test.ts": 1004, - "src/main/codex/hook-service-managed-install.test.ts": 286, - "src/main/codex/hook-service-runtime-trust-repair.test.ts": 330, - "src/main/codex/hook-service-trust-grant.test.ts": 355, - "src/main/codex/hook-service-user-hook-mirroring.test.ts": 371, - "src/main/codex/hook-service-wsl-runtime.test.ts": 389, - "src/main/codex/hook-trust-promotion.test.ts": 569, - "src/main/codex/managed-home-shell-preflight.test.ts": 161, + "src/main/cli/wsl-cli-powershell-boundary.test.ts": 97, + "src/main/cli/wsl-cli-registration-operation.test.ts": 104, + "src/main/cli/wsl-cli-registration-reconciliation.test.ts": 240, + "src/main/cli/wsl-cli-registration-registry.test.ts": 131, + "src/main/cli/wsl-managed-cli.test.ts": 64, + "src/main/cli/wsl-managed-cli.wsl.test.ts": 114, + "src/main/codebuddy/hook-service.test.ts": 102, + "src/main/codex-accounts/async-file-rename.test.ts": 1080, + "src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts": 69, + "src/main/codex-accounts/codex-auth-workspace-identity.test.ts": 58, + "src/main/codex-accounts/codex-config-mirror.test.ts": 315, + "src/main/codex-accounts/codex-credential-absence-grace.test.ts": 162, + "src/main/codex-accounts/codex-login-auth-url.test.ts": 54, + "src/main/codex-accounts/codex-reset-credit-ledger.test.ts": 294, + "src/main/codex-accounts/codex-windows-interactive-login.test.ts": 464, + "src/main/codex-accounts/fs-utils.test.ts": 126, + "src/main/codex-accounts/host-codex-managed-home-ownership.test.ts": 69, + "src/main/codex-accounts/legacy-shared-auth-migration.test.ts": 156, + "src/main/codex-accounts/legacy-shared-config-compatibility.test.ts": 147, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts": 12427, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts": 61, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts": 520, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts": 78, + "src/main/codex-accounts/managed-codex-auth-readiness.test.ts": 89, + "src/main/codex-accounts/runtime-home-catalog-account-key.test.ts": 1493, + "src/main/codex-accounts/runtime-home-catalog-probe-prep.test.ts": 381, + "src/main/codex-accounts/runtime-home-legacy-migration.test.ts": 869, + "src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts": 526, + "src/main/codex-accounts/runtime-home-mirror-login-copy.test.ts": 721, + "src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts": 350, + "src/main/codex-accounts/runtime-home-per-account-homes.test.ts": 1014, + "src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts": 480, + "src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts": 645, + "src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts": 470, + "src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts": 1229, + "src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts": 479, + "src/main/codex-accounts/runtime-home-session-migration-pass.test.ts": 555, + "src/main/codex-accounts/runtime-home-status-hook-independence.test.ts": 545, + "src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts": 1406, + "src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts": 949, + "src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts": 604, + "src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts": 273, + "src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts": 878, + "src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts": 875, + "src/main/codex-accounts/runtime-home-wsl-system-default.test.ts": 661, + "src/main/codex-accounts/runtime-selection.test.ts": 47, + "src/main/codex-accounts/service-abandoned-login-supersede.test.ts": 1252, + "src/main/codex-accounts/service-account-add-login.test.ts": 699, + "src/main/codex-accounts/service-account-selection-and-removal.test.ts": 701, + "src/main/codex-accounts/service-add-account-from-home.test.ts": 519, + "src/main/codex-accounts/service-login-process-teardown.test.ts": 465, + "src/main/codex-accounts/service-managed-home-removal-retries.test.ts": 516, + "src/main/codex-accounts/service-quota-refresh-decoupling.test.ts": 395, + "src/main/codex-accounts/service-reauthenticate-activation.test.ts": 433, + "src/main/codex-accounts/service-reset-credit-durability.test.ts": 932, + "src/main/codex-accounts/service-reset-credit-home-ownership.test.ts": 608, + "src/main/codex-accounts/service-reset-credit-target-routing.test.ts": 652, + "src/main/codex-accounts/service-system-default-identity.test.ts": 511, + "src/main/codex-accounts/service-wsl-accounts.test.ts": 740, + "src/main/codex-accounts/service.test.ts": 615, + "src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts": 525, + "src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts": 465, + "src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts": 528, + "src/main/codex-accounts/wsl-codex-command.test.ts": 50, + "src/main/codex-cli/codex-home-process-lock.test.ts": 53, + "src/main/codex-cli/command.test.ts": 90, + "src/main/codex-usage/codex-rollout-file-lifetime.test.ts": 160, + "src/main/codex-usage/codex-rollout-resume-state.test.ts": 75, + "src/main/codex-usage/codex-usage-cost-estimate.test.ts": 51, + "src/main/codex-usage/codex-usage-long-context-billing.test.ts": 491, + "src/main/codex-usage/scanner-attribution-memo.test.ts": 224, + "src/main/codex-usage/scanner-checkpoint-races.test.ts": 240, + "src/main/codex-usage/scanner-file-generation.test.ts": 197, + "src/main/codex-usage/scanner-file-identity.test.ts": 168, + "src/main/codex-usage/scanner-incremental-append.test.ts": 502, + "src/main/codex-usage/scanner-large-directory.test.ts": 673, + "src/main/codex-usage/scanner-paths.test.ts": 400, + "src/main/codex-usage/scanner.test.ts": 138, + "src/main/codex-usage/session-file-discovery-errors.test.ts": 71, + "src/main/codex-usage/store-automation-usage.test.ts": 385, + "src/main/codex-usage/store-model-pricing.test.ts": 431, + "src/main/codex-usage/store-orca-scope.test.ts": 404, + "src/main/codex-usage/store-persistence.test.ts": 360, + "src/main/codex-usage/store-snapshot.benchmark.test.ts": 637, + "src/main/codex/codex-account-session-bridge.test.ts": 376, + "src/main/codex/codex-account-session-index-heal.test.ts": 141, + "src/main/codex/codex-ai-vault-session-resume.test.ts": 145, + "src/main/codex/codex-app-server-capability-cache.test.ts": 65, + "src/main/codex/codex-app-server-client.test.ts": 11768, + "src/main/codex/codex-app-server-connection-exit-order.test.ts": 272, + "src/main/codex/codex-app-server-connection-tree-unproven.test.ts": 247, + "src/main/codex/codex-app-server-connection.test.ts": 1093, + "src/main/codex/codex-app-server-exit-error.test.ts": 202, + "src/main/codex/codex-app-server-session-stop.test.ts": 96, + "src/main/codex/codex-app-server-session-supervision.test.ts": 17428, + "src/main/codex/codex-app-server-session.test.ts": 278, + "src/main/codex/codex-app-server-teardown.integration.test.ts": 20731, + "src/main/codex/codex-background-command-tracker.test.ts": 377, + "src/main/codex/codex-background-task-tracker.test.ts": 326, + "src/main/codex/codex-background-terminals.test.ts": 476, + "src/main/codex/codex-child-work-evidence.test.ts": 416, + "src/main/codex/codex-child-work-translation-retention.test.ts": 424, + "src/main/codex/codex-close-turn-end.test.ts": 350, + "src/main/codex/codex-collab-call-delegation.test.ts": 435, + "src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts": 522, + "src/main/codex/codex-command-turn-claim.test.ts": 332, + "src/main/codex/codex-config-mirror-hook-trust-carry.test.ts": 149, + "src/main/codex/codex-config-mirror-mcp-ownership.test.ts": 151, + "src/main/codex/codex-config-mirror.test.ts": 225, + "src/main/codex/codex-config-settings-removal.test.ts": 68, + "src/main/codex/codex-configured-launch-defaults.test.ts": 58, + "src/main/codex/codex-daemon-socket-path-guard.test.ts": 189, + "src/main/codex/codex-default-mode-subagents.test.ts": 763, + "src/main/codex/codex-goal-journal-rows.test.ts": 82, + "src/main/codex/codex-home-paths.test.ts": 116, + "src/main/codex/codex-hook-approval-first-write.test.ts": 89, + "src/main/codex/codex-hook-command-form.test.ts": 121, + "src/main/codex/codex-hook-file-entry-binary-contract.test.ts": 228, + "src/main/codex/codex-hook-hash-lookup.test.ts": 180, + "src/main/codex/codex-hook-legacy-profile-block.test.ts": 144, + "src/main/codex/codex-hook-real-home-status.test.ts": 321, + "src/main/codex/codex-hook-reconcile.test.ts": 2030, + "src/main/codex/codex-hook-retired-commands.test.ts": 81, + "src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts": 211, + "src/main/codex/codex-hook-toggle-off-wins.test.ts": 276, + "src/main/codex/codex-hook-trust-cleanup.test.ts": 140, + "src/main/codex/codex-hook-trust-derivation.test.ts": 165, + "src/main/codex/codex-hook-trust-failure-memo.test.ts": 359, + "src/main/codex/codex-hook-trust-grant.test.ts": 594, + "src/main/codex/codex-hook-trust-memo.test.ts": 170, + "src/main/codex/codex-hooks-read-denial-followup.test.ts": 284, + "src/main/codex/codex-index-heal-binary-contract.test.ts": 146, + "src/main/codex/codex-interrupt-hook-trust.test.ts": 69, + "src/main/codex/codex-legacy-session-resume.test.ts": 453, + "src/main/codex/codex-model-catalog-probe.test.ts": 3579, + "src/main/codex/codex-model-provider-config.test.ts": 55, + "src/main/codex/codex-notice-item-translation.test.ts": 341, + "src/main/codex/codex-pane-account-registry-mutations.test.ts": 48, + "src/main/codex/codex-pane-launch-account.test.ts": 58, + "src/main/codex/codex-persistent-command-retention.test.ts": 416, + "src/main/codex/codex-pinned-rollout-proof.test.ts": 87, + "src/main/codex/codex-prompt-registry-retention.test.ts": 711, + "src/main/codex/codex-provider-retry-idle-sweep.test.ts": 1708, + "src/main/codex/codex-provider-retry-row.test.ts": 419, + "src/main/codex/codex-real-home-hook-entries.test.ts": 383, + "src/main/codex/codex-real-home-hook-key-spellings.test.ts": 283, + "src/main/codex/codex-real-home-hook-removal.test.ts": 214, + "src/main/codex/codex-real-home-path.test.ts": 70, + "src/main/codex/codex-real-home-shared-entry.test.ts": 433, + "src/main/codex/codex-real-home-windows-key-spellings.test.ts": 158, + "src/main/codex/codex-requested-close-turn-timing.test.ts": 341, + "src/main/codex/codex-rollout-session-meta.test.ts": 108, + "src/main/codex/codex-server-request-disposition.test.ts": 253, + "src/main/codex/codex-session-backfill-marker.test.ts": 92, + "src/main/codex/codex-session-backfill-scan-dates.test.ts": 68, + "src/main/codex/codex-session-backfill.test.ts": 314, + "src/main/codex/codex-session-bridge.test.ts": 122, + "src/main/codex/codex-session-index-heal-state.test.ts": 506, + "src/main/codex/codex-session-index-heal.test.ts": 2477, + "src/main/codex/codex-session-migration-recent-exits.test.ts": 44, + "src/main/codex/codex-session-migration-scheduler.test.ts": 436, + "src/main/codex/codex-session-resume-home.test.ts": 217, + "src/main/codex/codex-session-resume-preparation.test.ts": 68, + "src/main/codex/codex-session-resume-windows.test.ts": 60, + "src/main/codex/codex-session-resume-wrong-account.test.ts": 75, + "src/main/codex/codex-session-source-home.test.ts": 47, + "src/main/codex/codex-shared-server-fix.test.ts": 74, + "src/main/codex/codex-shared-server-pane.test.ts": 89, + "src/main/codex/codex-shared-server-probe.test.ts": 104, + "src/main/codex/codex-short-lived-app-server-spawn.test.ts": 431, + "src/main/codex/codex-stale-pane-accounts.test.ts": 118, + "src/main/codex/codex-state-db-backfill-recovery-process.test.ts": 75, + "src/main/codex/codex-state-db-backfill-recovery-supervision.test.ts": 8520, + "src/main/codex/codex-state-db-backfill-recovery.test.ts": 286, + "src/main/codex/codex-state-db.test.ts": 127, + "src/main/codex/codex-structured-acquisition-exit-proof.test.ts": 540, + "src/main/codex/codex-structured-child-environment.test.ts": 504, + "src/main/codex/codex-structured-child-work-producer.test.ts": 625, + "src/main/codex/codex-structured-compaction-refusal.test.ts": 377, + "src/main/codex/codex-structured-conversation-stop.test.ts": 808, + "src/main/codex/codex-structured-dispatch-admission.test.ts": 772, + "src/main/codex/codex-structured-dispatch-boundary.test.ts": 696, + "src/main/codex/codex-structured-dispatch-echo.test.ts": 76, + "src/main/codex/codex-structured-item-translation.test.ts": 205, + "src/main/codex/codex-structured-journal-compactions.test.ts": 406, + "src/main/codex/codex-structured-journal-exit-convergence.test.ts": 821, + "src/main/codex/codex-structured-journal-goal-admission.test.ts": 341, + "src/main/codex/codex-structured-journal-goal-resume.test.ts": 386, + "src/main/codex/codex-structured-journal-goal-revision.test.ts": 603, + "src/main/codex/codex-structured-journal-goal-rows.test.ts": 320, + "src/main/codex/codex-structured-journal-repeated-questions.test.ts": 446, + "src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts": 337, + "src/main/codex/codex-structured-journal-settlement-rollback.test.ts": 631, + "src/main/codex/codex-structured-journal-subagent-readers.test.ts": 744, + "src/main/codex/codex-structured-journal-translation-settlement.test.ts": 673, + "src/main/codex/codex-structured-journal-translation-streams.test.ts": 507, + "src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts": 337, + "src/main/codex/codex-structured-journal-translation-subagents.test.ts": 404, + "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts": 692, + "src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts": 363, + "src/main/codex/codex-structured-journal-translation-turn-state.test.ts": 71, + "src/main/codex/codex-structured-journal-translation.test.ts": 447, + "src/main/codex/codex-structured-journal-turn-endings-replay.test.ts": 314, + "src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts": 472, + "src/main/codex/codex-structured-launch-args.test.ts": 54, + "src/main/codex/codex-structured-launch-resolution.test.ts": 482, + "src/main/codex/codex-structured-live-provider-process.test.ts": 397, + "src/main/codex/codex-structured-owner-identity.test.ts": 83, + "src/main/codex/codex-structured-permission-policy.test.ts": 73, + "src/main/codex/codex-structured-prompt-items.test.ts": 304, + "src/main/codex/codex-structured-prompt-ownership.test.ts": 648, + "src/main/codex/codex-structured-prompt-replies.test.ts": 370, + "src/main/codex/codex-structured-question-order.test.ts": 1100, + "src/main/codex/codex-structured-real-cli-visuals.test.ts": 228, + "src/main/codex/codex-structured-reasoning-lifecycle.test.ts": 268, + "src/main/codex/codex-structured-rewind.test.ts": 502, + "src/main/codex/codex-structured-service-tier.test.ts": 649, + "src/main/codex/codex-structured-session-adapter-lifecycle.test.ts": 558, + "src/main/codex/codex-structured-session-adapter.test.ts": 645, + "src/main/codex/codex-structured-session-background-tasks.test.ts": 661, + "src/main/codex/codex-structured-session-cancel.test.ts": 566, + "src/main/codex/codex-structured-session-close.test.ts": 579, + "src/main/codex/codex-structured-session-exit-blame.test.ts": 486, + "src/main/codex/codex-structured-session-options-catalog.test.ts": 541, + "src/main/codex/codex-structured-session-options.test.ts": 609, + "src/main/codex/codex-structured-session-prompts.test.ts": 483, + "src/main/codex/codex-structured-session-shutdown.test.ts": 649, + "src/main/codex/codex-structured-sign-in.test.ts": 301, + "src/main/codex/codex-structured-thread-goal.test.ts": 339, + "src/main/codex/codex-structured-thread-open.test.ts": 392, + "src/main/codex/codex-structured-turn-end-settlement.test.ts": 1386, + "src/main/codex/codex-structured-turn-open-wait.test.ts": 1451, + "src/main/codex/codex-structured-turn-steer.test.ts": 800, + "src/main/codex/codex-structured-visuals.test.ts": 479, + "src/main/codex/codex-subagent-execution-projection.test.ts": 490, + "src/main/codex/codex-subagent-executions.test.ts": 83, + "src/main/codex/codex-subagent-interrupts.test.ts": 503, + "src/main/codex/codex-subagent-roster-retention.test.ts": 3130, + "src/main/codex/codex-subagent-roster.test.ts": 383, + "src/main/codex/codex-thread-token-totals.test.ts": 39, + "src/main/codex/codex-tool-identity-translation.test.ts": 105, + "src/main/codex/codex-trust-config-concurrent-launch.test.ts": 250, + "src/main/codex/codex-trust-config-mutation-queue.test.ts": 63, + "src/main/codex/codex-trust-grant-host.test.ts": 117, + "src/main/codex/codex-trust-grant-ledger.test.ts": 102, + "src/main/codex/codex-trust-grant-telemetry.test.ts": 161, + "src/main/codex/codex-turn-ordinals.test.ts": 89, + "src/main/codex/codex-user-hook-trust-moves.test.ts": 122, + "src/main/codex/codex-wsl-hook-install-plan.test.ts": 71, + "src/main/codex/config-features-settings-promotion.test.ts": 155, + "src/main/codex/config-plugin-registration-promotion.test.ts": 285, + "src/main/codex/config-settings-baseline-upgrade.test.ts": 169, + "src/main/codex/config-settings-promotion.test.ts": 280, + "src/main/codex/config-sync-stall.test.ts": 155, + "src/main/codex/config-toml-hook-state-duplicate-repair.test.ts": 123, + "src/main/codex/config-toml-hook-trust-scaling.test.ts": 120, + "src/main/codex/config-toml-mcp-servers.test.ts": 65, + "src/main/codex/config-toml-project-duplicate-repair.test.ts": 95, + "src/main/codex/config-toml-project-trust-spellings.test.ts": 113, + "src/main/codex/config-toml-promoted-setting-values.test.ts": 43, + "src/main/codex/config-toml-trust-hash.test.ts": 78, + "src/main/codex/config-toml-trust-hook-read.test.ts": 145, + "src/main/codex/config-toml-trust-hook-removal.test.ts": 152, + "src/main/codex/config-toml-trust-hook-upsert.test.ts": 194, + "src/main/codex/config-toml-trust-key.test.ts": 116, + "src/main/codex/config-toml-trust-loadability.test.ts": 182, + "src/main/codex/config-toml-trust-paths.test.ts": 100, + "src/main/codex/config-toml-trust-project.test.ts": 114, + "src/main/codex/config-toml-trust-stale-writes.test.ts": 71, + "src/main/codex/hook-service-concurrent-launch-install.test.ts": 1027, + "src/main/codex/hook-service-legacy-cleanup.test.ts": 907, + "src/main/codex/hook-service-managed-codex-hashes.test.ts": 646, + "src/main/codex/hook-service-managed-install.test.ts": 438, + "src/main/codex/hook-service-runtime-trust-repair.test.ts": 362, + "src/main/codex/hook-service-trust-grant.test.ts": 230, + "src/main/codex/hook-service-user-hook-mirroring.test.ts": 469, + "src/main/codex/hook-service-user-hook-trust-spellings.test.ts": 414, + "src/main/codex/hook-service-windows-key-spellings.test.ts": 220, + "src/main/codex/hook-service-wsl-runtime.test.ts": 366, + "src/main/codex/hook-trust-promotion.test.ts": 474, "src/main/codex/retained-codex-hook-state.test.ts": 41, - "src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts": 82, - "src/main/codex/sta-4737-unreadable-is-not-absent.test.ts": 179, - "src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts": 226, - "src/main/codex/windows-hook-command.test.ts": 161, - "src/main/codex/windows-hook-upgrade.test.ts": 122, - "src/main/codex/wsl-codex-session-bridge.test.ts": 133, - "src/main/command-code/hook-service.test.ts": 407, - "src/main/computer/computer-action-verification-normalization.test.ts": 37, - "src/main/computer/computer-provider-lifecycle.test.ts": 159, - "src/main/computer/computer-provider-unavailable-message.test.ts": 40, - "src/main/computer/computer-sidecar-diagnostics.test.ts": 37, - "src/main/computer/desktop-script-provider-action-errors.test.ts": 162, - "src/main/computer/desktop-script-provider-actions.test.ts": 167, - "src/main/computer/desktop-script-provider-bridge.test.ts": 45, - "src/main/computer/desktop-script-provider-cache-lifecycle.test.ts": 114, - "src/main/computer/desktop-script-provider-cache.test.ts": 191, - "src/main/computer/desktop-script-provider-client.test.ts": 205, - "src/main/computer/desktop-script-provider-errors.test.ts": 205, - "src/main/computer/desktop-script-provider-paste-validation.test.ts": 107, - "src/main/computer/desktop-script-provider-runtime-host-routing.test.ts": 204, - "src/main/computer/desktop-script-runtime-host.test.ts": 199, - "src/main/computer/desktop-script-runtime-host.win32.test.ts": 94, - "src/main/computer/desktop-script-serve-channel.test.ts": 170, - "src/main/computer/macos-computer-use-permission-status.test.ts": 97, - "src/main/computer/macos-computer-use-permissions.test.ts": 90, - "src/main/computer/macos-native-provider-client.test.ts": 1206, - "src/main/computer/macos-native-provider-line-buffer.test.ts": 62, - "src/main/computer/macos-native-provider-paste-validation.test.ts": 192, - "src/main/computer/macos-native-provider-process-reaping.test.ts": 94, - "src/main/computer/macos-native-provider-reaping.integration.test.ts": 4215, - "src/main/computer/macos-native-provider-socket.test.ts": 71, - "src/main/computer/macos-native-provider-startup-cleanup.test.ts": 73, - "src/main/computer/sidecar-client.test.ts": 96, - "src/main/computer/sidecar-provider-reaping.integration.test.ts": 313, - "src/main/computer/windows-powershell-execution-policy.test.ts": 62, - "src/main/copilot/hook-service.test.ts": 164, - "src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts": 34, - "src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts": 111, - "src/main/crash-reporting/crash-breadcrumb-store-orphan-cleanup.test.ts": 108, - "src/main/crash-reporting/crash-breadcrumb-store.test.ts": 166, - "src/main/crash-reporting/crash-report-copy-text.test.ts": 56, - "src/main/crash-reporting/crash-report-store.test.ts": 453, - "src/main/crash-reporting/crashpad-capture-read-limit.test.ts": 267, - "src/main/crash-reporting/crashpad-capture.test.ts": 94, - "src/main/crash-reporting/durable-crash-breadcrumb.test.ts": 71, - "src/main/crash-reporting/expected-teardown-state.test.ts": 38, - "src/main/crash-reporting/gpu-crash-diagnostics.test.ts": 49, - "src/main/crash-reporting/gpu-crash-fallback-decision.test.ts": 38, - "src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts": 40, - "src/main/crash-reporting/gpu-fallback-engagement.test.ts": 60, - "src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts": 69, - "src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts": 39, - "src/main/crash-reporting/main-process-lifecycle-identity.test.ts": 68, - "src/main/crash-reporting/minidump-crash-signature.test.ts": 59, - "src/main/crash-reporting/minidump-file-source.test.ts": 200, - "src/main/crash-reporting/pre-gone-host-memory.test.ts": 96, - "src/main/crash-reporting/process-gone-classification.test.ts": 51, - "src/main/crash-reporting/process-gone-dedupe.test.ts": 33, - "src/main/crash-reporting/process-gone-diagnostics.test.ts": 114, - "src/main/crash-reporting/process-gone-killed-one-ordering.test.ts": 108, - "src/main/crash-reporting/process-gone-recorder.test.ts": 967, - "src/main/crash-reporting/process-gone-sibling-correlation.test.ts": 686, - "src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts": 48, - "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 296, - "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 24, - "src/main/cursor/hook-service.test.ts": 281, - "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 2349, - "src/main/daemon/bash-prompt-command-composition.test.ts": 2955, - "src/main/daemon/client.test.ts": 523, - "src/main/daemon/cold-restore-payload-cache.test.ts": 34, - "src/main/daemon/daemon-adoption-telemetry-event.test.ts": 269, - "src/main/daemon/daemon-audit-eligibility-event.test.ts": 807, - "src/main/daemon/daemon-authenticated-client-activity.test.ts": 408, - "src/main/daemon/daemon-background-transient-facts.test.ts": 89, - "src/main/daemon/daemon-bundle-staleness.test.ts": 461, - "src/main/daemon/daemon-cgroup-scope.test.ts": 87, - "src/main/daemon/daemon-checkpoint-session-queue.test.ts": 243, - "src/main/daemon/daemon-client-notify-settlement.test.ts": 66, - "src/main/daemon/daemon-client-rpc-request.test.ts": 97, - "src/main/daemon/daemon-client-stream-keepalive.test.ts": 43, - "src/main/daemon/daemon-durable-checkpoint-live-rebase.test.ts": 1035, - "src/main/daemon/daemon-endpoint-ownership.test.ts": 525, - "src/main/daemon/daemon-endpoint-publish.test.ts": 114, - "src/main/daemon/daemon-endpoint-windows.test.ts": 50, - "src/main/daemon/daemon-entry-path-layouts.test.ts": 353, - "src/main/daemon/daemon-entry.test.ts": 517, - "src/main/daemon/daemon-errors.test.ts": 159, - "src/main/daemon/daemon-file-log.test.ts": 63, - "src/main/daemon/daemon-folder-access-mismatch.test.ts": 241, - "src/main/daemon/daemon-folder-access-probe-script.test.ts": 198, - "src/main/daemon/daemon-folder-access-probe.test.ts": 68, - "src/main/daemon/daemon-folder-access-reset.test.ts": 215, - "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts": 50, - "src/main/daemon/daemon-health-endpoint-entry.test.ts": 78, - "src/main/daemon/daemon-health-socket-cleanup.test.ts": 83, - "src/main/daemon/daemon-health.test.ts": 3556, - "src/main/daemon/daemon-host-relocation.test.ts": 486, - "src/main/daemon/daemon-idle-shutdown.test.ts": 1312, - "src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts": 52, - "src/main/daemon/daemon-incarnation-evidence.test.ts": 62, - "src/main/daemon/daemon-init-child-readiness.test.ts": 488, - "src/main/daemon/daemon-init-child-startup-failure.test.ts": 630, - "src/main/daemon/daemon-init-endpoint-adoption.test.ts": 1191, - "src/main/daemon/daemon-init-live-session-preservation.test.ts": 667, - "src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts": 303, - "src/main/daemon/daemon-init-provider-installation.test.ts": 493, - "src/main/daemon/daemon-init-replacement-reporting.test.ts": 568, - "src/main/daemon/daemon-init-restart-sequence.test.ts": 865, - "src/main/daemon/daemon-init-wedged-daemon-grace.test.ts": 452, - "src/main/daemon/daemon-launched-child-identity.test.ts": 119, - "src/main/daemon/daemon-launched-child-spawn.test.ts": 64, - "src/main/daemon/daemon-lifecycle-event.test.ts": 196, - "src/main/daemon/daemon-mac-code-identity.test.ts": 76, - "src/main/daemon/daemon-main.test.ts": 421, - "src/main/daemon/daemon-native-pty-exception.test.ts": 39, - "src/main/daemon/daemon-pid-file-parse.test.ts": 41, - "src/main/daemon/daemon-preflight-client-replacement.test.ts": 704, - "src/main/daemon/daemon-process-identity-time-zone.test.ts": 90, - "src/main/daemon/daemon-process-inspection.test.ts": 63, - "src/main/daemon/daemon-protocol-version.test.ts": 34, - "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts": 1246, - "src/main/daemon/daemon-pty-adapter-cold-restore-seed.test.ts": 1188, - "src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts": 887, - "src/main/daemon/daemon-pty-adapter-daemon-recovery.test.ts": 1496, - "src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts": 2334, - "src/main/daemon/daemon-pty-adapter-history-recovery.test.ts": 1236, - "src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts": 593, - "src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts": 1713, - "src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts": 2069, - "src/main/daemon/daemon-pty-adapter-replacement-exit-race.test.ts": 767, - "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts": 1235, - "src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts": 468, - "src/main/daemon/daemon-pty-adapter.test.ts": 4255, - "src/main/daemon/daemon-pty-router-history-handoff.test.ts": 1329, - "src/main/daemon/daemon-pty-router.test.ts": 658, - "src/main/daemon/daemon-pty-startup-delivery.test.ts": 2827, - "src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 679, - "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 410, - "src/main/daemon/daemon-ready-identity.test.ts": 55, - "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts": 10993, - "src/main/daemon/daemon-respawn-throttle.test.ts": 58, - "src/main/daemon/daemon-restore-scrollback-depth.test.ts": 3415, - "src/main/daemon/daemon-scope-death-watch.test.ts": 70, - "src/main/daemon/daemon-self-retirement-respawn.test.ts": 786, - "src/main/daemon/daemon-server-async-spawn-cancellation.test.ts": 1078, - "src/main/daemon/daemon-server-attach-only.test.ts": 466, - "src/main/daemon/daemon-server-attachment-lifecycle.test.ts": 631, - "src/main/daemon/daemon-server-error-handling.test.ts": 457, - "src/main/daemon/daemon-server-kill-attribution.test.ts": 256, - "src/main/daemon/daemon-server.test.ts": 2072, - "src/main/daemon/daemon-session-owner-resolution.test.ts": 60, - "src/main/daemon/daemon-session-scrollback-window.test.ts": 408, - "src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts": 667, - "src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 2293, - "src/main/daemon/daemon-spawner.test.ts": 472, - "src/main/daemon/daemon-stream-backpressure-socket.test.ts": 305, - "src/main/daemon/daemon-stream-backpressure.test.ts": 42, - "src/main/daemon/daemon-stream-data-batcher.test.ts": 196, - "src/main/daemon/daemon-stream-data-split.test.ts": 1339, - "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts": 485, - "src/main/daemon/daemon-stream-droppable-membership.test.ts": 127, - "src/main/daemon/daemon-stream-producer-stall-watchdog.test.ts": 74, - "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 91, - "src/main/daemon/daemon-tcc-attribution.test.ts": 75, - "src/main/daemon/daemon-transport-attachment-release.test.ts": 791, - "src/main/daemon/degraded-daemon-pty-provider.test.ts": 151, - "src/main/daemon/hangul-cell-width-agreement.test.ts": 91, - "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 18873, - "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 124, - "src/main/daemon/headless-emulator-shrink-snapshot.test.ts": 177, - "src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts": 53, - "src/main/daemon/headless-emulator-unicode-width.test.ts": 108, - "src/main/daemon/headless-emulator-wide-char-repaint.test.ts": 1505, - "src/main/daemon/headless-emulator-wide-char-snapshot.test.ts": 265, - "src/main/daemon/headless-emulator.test.ts": 232, - "src/main/daemon/headless-osc-link-ranges.test.ts": 141, - "src/main/daemon/hibernation-cold-restore-repro.test.ts": 202, - "src/main/daemon/history-manager-disabled-sessions-leak.test.ts": 150, - "src/main/daemon/history-manager.test.ts": 437, - "src/main/daemon/history-reader-memory.test.ts": 366, - "src/main/daemon/history-reader.test.ts": 306, - "src/main/daemon/issue-6814-daemon-failure-classification.test.ts": 6483, - "src/main/daemon/macos-login-session-death-watch.test.ts": 93, - "src/main/daemon/ndjson.test.ts": 180, - "src/main/daemon/node-pty-error-hints.test.ts": 46, - "src/main/daemon/node-pty-windows-input-error.win32.test.ts": 37, - "src/main/daemon/osc7-file-uri.test.ts": 48, - "src/main/daemon/osc7-uri-extraction.test.ts": 34, - "src/main/daemon/post-ready-flush-gate.test.ts": 43, - "src/main/daemon/process-boundary-ground.test.ts": 256, - "src/main/daemon/process-boundary-host-focus.test.ts": 314, - "src/main/daemon/pty-session-id.test.ts": 54, - "src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts": 277, - "src/main/daemon/pty-subprocess-env-inheritance.test.ts": 312, - "src/main/daemon/pty-subprocess-env-retention.test.ts": 248, - "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts": 235, - "src/main/daemon/pty-subprocess-foreground-identity.test.ts": 589, - "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts": 336, - "src/main/daemon/pty-subprocess-git-credential-guard.test.ts": 249, - "src/main/daemon/pty-subprocess-handle-lifecycle.test.ts": 413, - "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts": 10411, - "src/main/daemon/pty-subprocess-io-failure-native.test.ts": 2523, - "src/main/daemon/pty-subprocess-managed-agent-env.test.ts": 337, - "src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts": 440, - "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 310, - "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 370, - "src/main/daemon/pty-subprocess.test.ts": 269, - "src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts": 83, - "src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts": 102, - "src/main/daemon/reattach-snapshot.test.ts": 878, - "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 156, - "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 122, - "src/main/daemon/serialize-addon-edge-cases.test.ts": 71, - "src/main/daemon/serialize-grid-transcript-replay.test.ts": 14365, - "src/main/daemon/serialize-grid.differential.fuzz.test.ts": 582, - "src/main/daemon/session-ingest-throughput.bench.test.ts": 191, - "src/main/daemon/session-pending-output.test.ts": 426, - "src/main/daemon/session-producer-pause.test.ts": 33, - "src/main/daemon/session-shell-recovery.test.ts": 338, - "src/main/daemon/session-terminal-control.test.ts": 245, - "src/main/daemon/session.test.ts": 626, - "src/main/daemon/shell-ready-bash-wrapper.test.ts": 337, - "src/main/daemon/slow-daemon-session-verification.test.ts": 14043, - "src/main/daemon/startup-device-attributes-responder.test.ts": 38, - "src/main/daemon/terminal-attach-cancellation.test.ts": 24, - "src/main/daemon/terminal-checkpoint-serializer.test.ts": 127, - "src/main/daemon/terminal-checkpoint-writer-bounds.test.ts": 237, - "src/main/daemon/terminal-cursor-line-context.test.ts": 124, - "src/main/daemon/terminal-descendant-shutdown.test.ts": 97, - "src/main/daemon/terminal-history-incremental-restore.test.ts": 505, - "src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts": 7048, - "src/main/daemon/terminal-history-log-encode-allocation.test.ts": 96, - "src/main/daemon/terminal-history-log.test.ts": 54, - "src/main/daemon/terminal-history-permissions.test.ts": 202, - "src/main/daemon/terminal-history-restorable-retention.test.ts": 34, - "src/main/daemon/terminal-history-seed-chunks.test.ts": 64, - "src/main/daemon/terminal-history-seed-segments.test.ts": 78, - "src/main/daemon/terminal-history-seed-transfer-registry.test.ts": 62, - "src/main/daemon/terminal-history-session-tombstone-retry.test.ts": 68, - "src/main/daemon/terminal-host-agent-session.test.ts": 227, - "src/main/daemon/terminal-host-attach-only.test.ts": 297, - "src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts": 196, - "src/main/daemon/terminal-host-concurrent-create.test.ts": 240, - "src/main/daemon/terminal-host-cwd-readability.test.ts": 261, - "src/main/daemon/terminal-host-non-agent-foreground.test.ts": 189, - "src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts": 163, - "src/main/daemon/terminal-host-process-inspection.test.ts": 236, - "src/main/daemon/terminal-host-pty-owner-backend.test.ts": 324, - "src/main/daemon/terminal-host-readiness-reporting.test.ts": 317, - "src/main/daemon/terminal-host-session-reaping-leak.test.ts": 460, - "src/main/daemon/terminal-host-spawn-input-retention.test.ts": 543, - "src/main/daemon/terminal-host-startup.test.ts": 299, - "src/main/daemon/terminal-host-teardown-recreate.test.ts": 484, - "src/main/daemon/terminal-host-wsl-context.test.ts": 249, - "src/main/daemon/terminal-host.test.ts": 691, - "src/main/daemon/terminal-session-teardown.test.ts": 95, - "src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts": 215, - "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 56, - "src/main/daemon/terminal-shell-recovery-barrier.test.ts": 748, - "src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts": 61, - "src/main/daemon/terminal-snapshot-color-parity.test.ts": 147, - "src/main/daemon/terminal-snapshot-osc8-roundtrip.test.ts": 102, - "src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts": 286, - "src/main/daemon/windows-conpty-warmup.test.ts": 113, - "src/main/daemon/wsl-cold-restore-cwd.test.ts": 54, - "src/main/destination-serialized-local-rename.test.ts": 3857, - "src/main/devin/hook-config-json.test.ts": 89, - "src/main/devin/hook-service.test.ts": 138, - "src/main/diagnostics/main-thread-churn-probe.test.ts": 63, - "src/main/dock/unread-badge.test.ts": 59, - "src/main/droid/hook-service.test.ts": 53, - "src/main/durable-file-write-syscall-proof.test.ts": 72, - "src/main/durable-file-write.test.ts": 167, - "src/main/emulator/android/adb-devices.test.ts": 54, - "src/main/emulator/android/android-app-control.test.ts": 53, - "src/main/emulator/android/android-capability-operations.test.ts": 82, - "src/main/emulator/android/android-device-inventory.test.ts": 48, - "src/main/emulator/android/android-input-commands.test.ts": 47, + "src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts": 110, + "src/main/codex/sta-4737-unreadable-is-not-absent.test.ts": 161, + "src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts": 193, + "src/main/codex/windows-hook-command.test.ts": 119, + "src/main/codex/windows-hook-upgrade.test.ts": 269, + "src/main/codex/wsl-codex-session-bridge.test.ts": 125, + "src/main/command-code/hook-service.test.ts": 255, + "src/main/computer/computer-action-verification-normalization.test.ts": 38, + "src/main/computer/computer-provider-lifecycle.test.ts": 152, + "src/main/computer/computer-provider-unavailable-message.test.ts": 45, + "src/main/computer/computer-sidecar-diagnostics.test.ts": 53, + "src/main/computer/desktop-script-provider-action-errors.test.ts": 149, + "src/main/computer/desktop-script-provider-actions.test.ts": 136, + "src/main/computer/desktop-script-provider-bridge.test.ts": 50, + "src/main/computer/desktop-script-provider-cache-lifecycle.test.ts": 163, + "src/main/computer/desktop-script-provider-cache.test.ts": 148, + "src/main/computer/desktop-script-provider-client.test.ts": 216, + "src/main/computer/desktop-script-provider-errors.test.ts": 290, + "src/main/computer/desktop-script-provider-paste-validation.test.ts": 123, + "src/main/computer/desktop-script-provider-runtime-host-routing.test.ts": 231, + "src/main/computer/desktop-script-runtime-host.test.ts": 248, + "src/main/computer/desktop-script-runtime-host.win32.test.ts": 80, + "src/main/computer/desktop-script-serve-channel.test.ts": 112, + "src/main/computer/macos-computer-use-permission-status.test.ts": 101, + "src/main/computer/macos-computer-use-permissions.test.ts": 115, + "src/main/computer/macos-native-provider-client.test.ts": 1179, + "src/main/computer/macos-native-provider-line-buffer.test.ts": 63, + "src/main/computer/macos-native-provider-paste-validation.test.ts": 166, + "src/main/computer/macos-native-provider-process-reaping.test.ts": 103, + "src/main/computer/macos-native-provider-reaping.integration.test.ts": 4160, + "src/main/computer/macos-native-provider-socket.test.ts": 60, + "src/main/computer/macos-native-provider-startup-cleanup.test.ts": 82, + "src/main/computer/sidecar-client.test.ts": 130, + "src/main/computer/sidecar-provider-reaping.integration.test.ts": 335, + "src/main/computer/windows-powershell-execution-policy.test.ts": 54, + "src/main/copilot/hook-service.test.ts": 112, + "src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts": 61, + "src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts": 123, + "src/main/crash-reporting/crash-breadcrumb-store-orphan-cleanup.test.ts": 96, + "src/main/crash-reporting/crash-breadcrumb-store.test.ts": 156, + "src/main/crash-reporting/crash-report-copy-text.test.ts": 58, + "src/main/crash-reporting/crash-report-store.test.ts": 455, + "src/main/crash-reporting/crashpad-capture-read-limit.test.ts": 274, + "src/main/crash-reporting/crashpad-capture.test.ts": 102, + "src/main/crash-reporting/durable-crash-breadcrumb.test.ts": 67, + "src/main/crash-reporting/expected-teardown-state.test.ts": 57, + "src/main/crash-reporting/gpu-crash-diagnostics.test.ts": 63, + "src/main/crash-reporting/gpu-crash-fallback-decision.test.ts": 49, + "src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts": 48, + "src/main/crash-reporting/gpu-fallback-engagement.test.ts": 78, + "src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts": 53, + "src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts": 55, + "src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts": 61, + "src/main/crash-reporting/main-process-lifecycle-identity.test.ts": 51, + "src/main/crash-reporting/minidump-crash-signature.test.ts": 88, + "src/main/crash-reporting/minidump-file-source.test.ts": 248, + "src/main/crash-reporting/pre-gone-host-memory.test.ts": 114, + "src/main/crash-reporting/process-gone-classification.test.ts": 56, + "src/main/crash-reporting/process-gone-dedupe.test.ts": 52, + "src/main/crash-reporting/process-gone-diagnostics.test.ts": 112, + "src/main/crash-reporting/process-gone-killed-one-ordering.test.ts": 172, + "src/main/crash-reporting/process-gone-recorder.test.ts": 767, + "src/main/crash-reporting/process-gone-sibling-correlation.test.ts": 643, + "src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts": 66, + "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 126, + "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 49, + "src/main/credential-file-protection.test.ts": 44, + "src/main/cursor/hook-command.live-shell.test.ts": 562, + "src/main/cursor/hook-service.test.ts": 264, + "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 2622, + "src/main/daemon/bash-prompt-command-composition.test.ts": 3412, + "src/main/daemon/client.test.ts": 426, + "src/main/daemon/cold-restore-payload-cache.test.ts": 58, + "src/main/daemon/daemon-adoption-telemetry-event.test.ts": 399, + "src/main/daemon/daemon-audit-eligibility-event.test.ts": 962, + "src/main/daemon/daemon-authenticated-client-activity.test.ts": 666, + "src/main/daemon/daemon-background-transient-facts.test.ts": 97, + "src/main/daemon/daemon-bundle-staleness.test.ts": 425, + "src/main/daemon/daemon-cgroup-scope.test.ts": 105, + "src/main/daemon/daemon-checkpoint-session-queue.test.ts": 227, + "src/main/daemon/daemon-client-frame-validation.test.ts": 552, + "src/main/daemon/daemon-client-notify-settlement.test.ts": 52, + "src/main/daemon/daemon-client-reader-handoff.test.ts": 61, + "src/main/daemon/daemon-client-rpc-request.test.ts": 80, + "src/main/daemon/daemon-client-stream-keepalive.test.ts": 53, + "src/main/daemon/daemon-color-query-reply-colors.test.ts": 857, + "src/main/daemon/daemon-durable-checkpoint-live-rebase.test.ts": 990, + "src/main/daemon/daemon-endpoint-ownership.test.ts": 648, + "src/main/daemon/daemon-endpoint-publish.test.ts": 219, + "src/main/daemon/daemon-endpoint-windows.test.ts": 61, + "src/main/daemon/daemon-entry-path-layouts.test.ts": 546, + "src/main/daemon/daemon-entry.test.ts": 811, + "src/main/daemon/daemon-errors.test.ts": 293, + "src/main/daemon/daemon-file-log.test.ts": 83, + "src/main/daemon/daemon-folder-access-mismatch.test.ts": 300, + "src/main/daemon/daemon-folder-access-probe-script.test.ts": 194, + "src/main/daemon/daemon-folder-access-probe.test.ts": 66, + "src/main/daemon/daemon-folder-access-reset.test.ts": 296, + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts": 60, + "src/main/daemon/daemon-health-endpoint-entry.test.ts": 121, + "src/main/daemon/daemon-health-socket-cleanup.test.ts": 124, + "src/main/daemon/daemon-health.test.ts": 3789, + "src/main/daemon/daemon-host-current-version-reclaim.test.ts": 200, + "src/main/daemon/daemon-host-entry-layout.test.ts": 175, + "src/main/daemon/daemon-host-file-hash-cache.test.ts": 164, + "src/main/daemon/daemon-host-process-package.test.ts": 315, + "src/main/daemon/daemon-host-profile-isolation.test.ts": 158, + "src/main/daemon/daemon-host-relocation.test.ts": 333, + "src/main/daemon/daemon-idle-inventory-budget.test.ts": 651, + "src/main/daemon/daemon-idle-shutdown.test.ts": 9146, + "src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts": 127, + "src/main/daemon/daemon-incarnation-evidence.test.ts": 130, + "src/main/daemon/daemon-init-child-readiness.test.ts": 674, + "src/main/daemon/daemon-init-child-startup-failure.test.ts": 1005, + "src/main/daemon/daemon-init-endpoint-adoption.test.ts": 1604, + "src/main/daemon/daemon-init-live-session-preservation.test.ts": 961, + "src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts": 539, + "src/main/daemon/daemon-init-provider-installation.test.ts": 509, + "src/main/daemon/daemon-init-replacement-reporting.test.ts": 896, + "src/main/daemon/daemon-init-restart-sequence.test.ts": 1523, + "src/main/daemon/daemon-init-wedged-daemon-grace.test.ts": 801, + "src/main/daemon/daemon-launched-child-identity.test.ts": 82, + "src/main/daemon/daemon-launched-child-spawn.test.ts": 65, + "src/main/daemon/daemon-lifecycle-event.test.ts": 312, + "src/main/daemon/daemon-mac-code-identity.test.ts": 70, + "src/main/daemon/daemon-main.test.ts": 781, + "src/main/daemon/daemon-native-pty-exception.test.ts": 62, + "src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts": 218, + "src/main/daemon/daemon-pid-file-parse.test.ts": 44, + "src/main/daemon/daemon-preflight-client-replacement.test.ts": 927, + "src/main/daemon/daemon-process-identity-time-zone.test.ts": 57, + "src/main/daemon/daemon-process-inspection.test.ts": 95, + "src/main/daemon/daemon-provider-census.test.ts": 120, + "src/main/daemon/daemon-provider-routing.test.ts": 331, + "src/main/daemon/daemon-pty-adapter-closing-session-inventory.test.ts": 822, + "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts": 1311, + "src/main/daemon/daemon-pty-adapter-cold-restore-seed.test.ts": 1180, + "src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts": 1076, + "src/main/daemon/daemon-pty-adapter-daemon-recovery.test.ts": 1414, + "src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts": 2079, + "src/main/daemon/daemon-pty-adapter-history-recovery.test.ts": 1304, + "src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts": 827, + "src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts": 1578, + "src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts": 1037, + "src/main/daemon/daemon-pty-adapter-replacement-exit-race.test.ts": 870, + "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts": 1432, + "src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts": 649, + "src/main/daemon/daemon-pty-adapter.test.ts": 1757, + "src/main/daemon/daemon-pty-router-history-handoff.test.ts": 1347, + "src/main/daemon/daemon-pty-router.test.ts": 485, + "src/main/daemon/daemon-pty-startup-delivery.test.ts": 3028, + "src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 836, + "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 684, + "src/main/daemon/daemon-ready-identity.test.ts": 56, + "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts": 949, + "src/main/daemon/daemon-respawn-throttle.test.ts": 106, + "src/main/daemon/daemon-restore-scrollback-depth.test.ts": 2335, + "src/main/daemon/daemon-router-retirement.test.ts": 67, + "src/main/daemon/daemon-scope-death-watch.test.ts": 89, + "src/main/daemon/daemon-self-retirement-respawn.test.ts": 1065, + "src/main/daemon/daemon-server-async-spawn-cancellation.test.ts": 1280, + "src/main/daemon/daemon-server-attach-only.test.ts": 738, + "src/main/daemon/daemon-server-attachment-lifecycle.test.ts": 919, + "src/main/daemon/daemon-server-error-handling.test.ts": 818, + "src/main/daemon/daemon-server-kill-attribution.test.ts": 693, + "src/main/daemon/daemon-server.test.ts": 1370, + "src/main/daemon/daemon-session-owner-resolution.test.ts": 89, + "src/main/daemon/daemon-session-scrollback-window.test.ts": 477, + "src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts": 856, + "src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 2550, + "src/main/daemon/daemon-socket-endpoint-path.test.ts": 428, + "src/main/daemon/daemon-spawner.test.ts": 728, + "src/main/daemon/daemon-stream-backpressure-socket.test.ts": 454, + "src/main/daemon/daemon-stream-backpressure.test.ts": 71, + "src/main/daemon/daemon-stream-binary-framing.test.ts": 69, + "src/main/daemon/daemon-stream-binary-negotiation.test.ts": 550, + "src/main/daemon/daemon-stream-binary-unicode.test.ts": 60, + "src/main/daemon/daemon-stream-data-batcher.test.ts": 235, + "src/main/daemon/daemon-stream-data-split.test.ts": 700, + "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts": 644, + "src/main/daemon/daemon-stream-droppable-membership.test.ts": 131, + "src/main/daemon/daemon-stream-framing-replacement.test.ts": 58, + "src/main/daemon/daemon-stream-producer-stall-watchdog.test.ts": 126, + "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 132, + "src/main/daemon/daemon-tcc-attribution.test.ts": 101, + "src/main/daemon/daemon-transport-attachment-release.test.ts": 790, + "src/main/daemon/daemon-user-manager-lifetime.test.ts": 77, + "src/main/daemon/degraded-daemon-pty-provider.test.ts": 126, + "src/main/daemon/hangul-cell-width-agreement.test.ts": 100, + "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 2924, + "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 161, + "src/main/daemon/headless-emulator-shrink-snapshot.test.ts": 175, + "src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts": 101, + "src/main/daemon/headless-emulator-unicode-width.test.ts": 141, + "src/main/daemon/headless-emulator-wide-char-repaint.test.ts": 1711, + "src/main/daemon/headless-emulator-wide-char-snapshot.test.ts": 273, + "src/main/daemon/headless-emulator.test.ts": 341, + "src/main/daemon/headless-osc-link-ranges.test.ts": 185, + "src/main/daemon/hibernation-cold-restore-repro.test.ts": 203, + "src/main/daemon/history-manager-disabled-sessions-leak.test.ts": 173, + "src/main/daemon/history-manager.test.ts": 371, + "src/main/daemon/history-reader-memory.test.ts": 440, + "src/main/daemon/history-reader.test.ts": 242, + "src/main/daemon/issue-6814-daemon-failure-classification.test.ts": 835, + "src/main/daemon/macos-login-session-death-watch.test.ts": 89, + "src/main/daemon/ndjson.test.ts": 135, + "src/main/daemon/node-pty-error-hints.test.ts": 50, + "src/main/daemon/node-pty-windows-input-error.win32.test.ts": 52, + "src/main/daemon/osc7-file-uri.test.ts": 49, + "src/main/daemon/osc7-uri-extraction.test.ts": 49, + "src/main/daemon/post-ready-flush-gate.test.ts": 67, + "src/main/daemon/process-boundary-ground.test.ts": 230, + "src/main/daemon/process-boundary-host-focus.test.ts": 296, + "src/main/daemon/pty-session-id.test.ts": 68, + "src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts": 454, + "src/main/daemon/pty-subprocess-env-inheritance.test.ts": 436, + "src/main/daemon/pty-subprocess-env-retention.test.ts": 361, + "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts": 528, + "src/main/daemon/pty-subprocess-foreground-identity.test.ts": 719, + "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts": 428, + "src/main/daemon/pty-subprocess-git-credential-guard.test.ts": 439, + "src/main/daemon/pty-subprocess-handle-lifecycle.test.ts": 525, + "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts": 1307, + "src/main/daemon/pty-subprocess-io-failure-native.test.ts": 2779, + "src/main/daemon/pty-subprocess-managed-agent-env.test.ts": 559, + "src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts": 479, + "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 434, + "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 482, + "src/main/daemon/pty-subprocess.test.ts": 441, + "src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts": 87, + "src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts": 148, + "src/main/daemon/pty-subprocess/spawn-preflight-health-probe.test.ts": 91, + "src/main/daemon/reattach-snapshot.test.ts": 889, + "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 284, + "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 163, + "src/main/daemon/serialize-addon-edge-cases.test.ts": 104, + "src/main/daemon/serialize-grid-cell-descriptors.test.ts": 78, + "src/main/daemon/serialize-grid-transcript-replay.test.ts": 24288, + "src/main/daemon/serialize-grid.differential.fuzz.test.ts": 559, + "src/main/daemon/session-ingest-throughput.bench.test.ts": 203, + "src/main/daemon/session-pending-output.test.ts": 350, + "src/main/daemon/session-producer-pause.test.ts": 59, + "src/main/daemon/session-reset-input-modes.test.ts": 243, + "src/main/daemon/session-shell-recovery.test.ts": 332, + "src/main/daemon/session-terminal-control.test.ts": 275, + "src/main/daemon/session.test.ts": 359, + "src/main/daemon/shell-ready-bash-wrapper.test.ts": 429, + "src/main/daemon/slow-daemon-session-verification.test.ts": 14993, + "src/main/daemon/startup-device-attributes-responder.test.ts": 57, + "src/main/daemon/terminal-attach-cancellation.test.ts": 40, + "src/main/daemon/terminal-checkpoint-serializer.test.ts": 255, + "src/main/daemon/terminal-checkpoint-writer-bounds.test.ts": 310, + "src/main/daemon/terminal-cursor-line-context.test.ts": 146, + "src/main/daemon/terminal-descendant-shutdown.test.ts": 124, + "src/main/daemon/terminal-history-incremental-restore.test.ts": 443, + "src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts": 1922, + "src/main/daemon/terminal-history-log-encode-allocation.test.ts": 87, + "src/main/daemon/terminal-history-log.test.ts": 60, + "src/main/daemon/terminal-history-permissions.test.ts": 251, + "src/main/daemon/terminal-history-restorable-retention.test.ts": 55, + "src/main/daemon/terminal-history-seed-chunks.test.ts": 76, + "src/main/daemon/terminal-history-seed-segments.test.ts": 143, + "src/main/daemon/terminal-history-seed-transfer-registry.test.ts": 49, + "src/main/daemon/terminal-history-session-tombstone-retry.test.ts": 81, + "src/main/daemon/terminal-host-agent-session.test.ts": 391, + "src/main/daemon/terminal-host-attach-only.test.ts": 359, + "src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts": 237, + "src/main/daemon/terminal-host-closing-session-listing.test.ts": 644, + "src/main/daemon/terminal-host-concurrent-create.test.ts": 397, + "src/main/daemon/terminal-host-cwd-readability.test.ts": 422, + "src/main/daemon/terminal-host-non-agent-foreground.test.ts": 179, + "src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts": 239, + "src/main/daemon/terminal-host-process-inspection.test.ts": 360, + "src/main/daemon/terminal-host-pty-owner-backend.test.ts": 366, + "src/main/daemon/terminal-host-readiness-reporting.test.ts": 447, + "src/main/daemon/terminal-host-session-reaping-leak.test.ts": 411, + "src/main/daemon/terminal-host-spawn-input-retention.test.ts": 641, + "src/main/daemon/terminal-host-startup-staging.test.ts": 355, + "src/main/daemon/terminal-host-startup.test.ts": 374, + "src/main/daemon/terminal-host-teardown-recreate.test.ts": 501, + "src/main/daemon/terminal-host-wsl-context.test.ts": 380, + "src/main/daemon/terminal-host.test.ts": 605, + "src/main/daemon/terminal-session-teardown.test.ts": 77, + "src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts": 256, + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 69, + "src/main/daemon/terminal-shell-recovery-barrier.test.ts": 1248, + "src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts": 72, + "src/main/daemon/terminal-snapshot-color-parity.test.ts": 136, + "src/main/daemon/terminal-snapshot-mouse-encoding.test.ts": 139, + "src/main/daemon/terminal-snapshot-osc8-roundtrip.test.ts": 130, + "src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts": 222, + "src/main/daemon/windows-conpty-warmup.test.ts": 56, + "src/main/daemon/wsl-cold-restore-cwd.test.ts": 47, + "src/main/destination-serialized-local-rename.test.ts": 2512, + "src/main/devin/hook-config-json.test.ts": 86, + "src/main/devin/hook-service.test.ts": 131, + "src/main/diagnostics/main-thread-churn-probe.test.ts": 69, + "src/main/dock/unread-badge.test.ts": 69, + "src/main/droid/hook-service.test.ts": 89, + "src/main/dsh/dsh-home-patch.test.ts": 45, + "src/main/dsh/hook-service.test.ts": 106, + "src/main/durable-file-write-syscall-proof.test.ts": 67, + "src/main/durable-file-write.test.ts": 139, + "src/main/emulator/android/adb-devices.test.ts": 65, + "src/main/emulator/android/android-app-control.test.ts": 48, + "src/main/emulator/android/android-capability-operations.test.ts": 100, + "src/main/emulator/android/android-device-inventory.test.ts": 66, + "src/main/emulator/android/android-input-commands.test.ts": 49, "src/main/emulator/android/android-input-mapping.test.ts": 63, - "src/main/emulator/android/android-logcat.test.ts": 35, - "src/main/emulator/android/android-permissions.test.ts": 32, - "src/main/emulator/android/android-sdk-discovery.test.ts": 58, - "src/main/emulator/android/avd-manager.test.ts": 51, - "src/main/emulator/android/scrcpy-server-deploy.test.ts": 31, - "src/main/emulator/android/scrcpy-stream-session.test.ts": 2989, - "src/main/emulator/android/scrcpy-video-frame-parser.test.ts": 238, - "src/main/emulator/android/uiautomator-tree.test.ts": 33, - "src/main/emulator/backends/android-emulator-backend.test.ts": 176, - "src/main/emulator/backends/ios-emulator-backend.test.ts": 162, - "src/main/emulator/emulator-availability.test.ts": 52, - "src/main/emulator/emulator-bridge.test.ts": 417, - "src/main/emulator/emulator-gesture-sender.test.ts": 194, - "src/main/emulator/emulator-session-registry.test.ts": 38, - "src/main/emulator/emulator-start-lease-registry.test.ts": 116, - "src/main/emulator/mjpeg-frame-parser.test.ts": 50, - "src/main/emulator/mjpeg-frame-stream.test.ts": 143, - "src/main/emulator/scrcpy-video-registry.test.ts": 43, - "src/main/emulator/serve-sim-accessibility-tree.test.ts": 134, - "src/main/emulator/serve-sim-ax-normalization.test.ts": 50, - "src/main/emulator/serve-sim-detached-session.test.ts": 32, - "src/main/emulator/serve-sim-execution.test.ts": 100, - "src/main/emulator/serve-sim-helper-processes.test.ts": 64, - "src/main/emulator/serve-sim-runtime-materializer.test.ts": 98, - "src/main/emulator/simctl-simulator-devices.test.ts": 61, - "src/main/ephemeral-vm-recipe-runner.test.ts": 516, - "src/main/ephemeral-vm-resume-integrity.test.ts": 163, - "src/main/ephemeral-vm-runtime-service.test.ts": 944, - "src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts": 133, - "src/main/external-editor-launch.test.ts": 58, - "src/main/fish-history-session.test.ts": 91, - "src/main/folder-upgrade-worktree-path.test.ts": 45, - "src/main/gemini/hook-service.test.ts": 59, - "src/main/ghostty/discovery.test.ts": 79, - "src/main/ghostty/index.test.ts": 89, - "src/main/ghostty/mapper-extended.test.ts": 56, - "src/main/ghostty/mapper.test.ts": 74, - "src/main/ghostty/parser.test.ts": 40, - "src/main/ghostty/theme-import.test.ts": 100, - "src/main/ghostty/theme-resolution.test.ts": 75, - "src/main/git-bash.test.ts": 49, - "src/main/git/add-sparse-worktree.test.ts": 166, - "src/main/git/admission-tier-plumbing.test.ts": 38, - "src/main/git/branch-rename.test.ts": 50, - "src/main/git/canonical-repo-key.test.ts": 112, - "src/main/git/check-ignored-paths.test.ts": 45, - "src/main/git/coalesced-probe.test.ts": 54, - "src/main/git/command-runner/gh-exec-file-deadline.test.ts": 135, - "src/main/git/command-runner/gh-spawn-boundary.test.ts": 802, - "src/main/git/command-runner/git-admission-output-parity.test.ts": 246, - "src/main/git/command-runner/git-admission-span.test.ts": 324, - "src/main/git/command-runner/git-admission-storm-measurement.test.ts": 26034, - "src/main/git/command-runner/git-command-timeout-behavior.test.ts": 1860, - "src/main/git/command-runner/git-command-timeout.test.ts": 55, - "src/main/git/command-runner/git-exec-admission-lifetime.test.ts": 485, - "src/main/git/command-runner/git-operation-executor.test.ts": 86, - "src/main/git/command-runner/git-spawn-admission-lifetime.test.ts": 108, - "src/main/git/command-runner/git-stream-admission-lifetime.test.ts": 224, - "src/main/git/command-runner/git-subprocess-admission.test.ts": 364, - "src/main/git/command-runner/hosted-cli-deadline-log.test.ts": 103, - "src/main/git/command-runner/spawned-command-tree-kill.test.ts": 43, - "src/main/git/command-runner/wsl-command-resolution-windows-binary.test.ts": 95, - "src/main/git/command-runner/wsl-host-failure.test.ts": 155, + "src/main/emulator/android/android-logcat.test.ts": 54, + "src/main/emulator/android/android-permissions.test.ts": 49, + "src/main/emulator/android/android-sdk-discovery.test.ts": 61, + "src/main/emulator/android/avd-manager.test.ts": 61, + "src/main/emulator/android/scrcpy-server-deploy.test.ts": 53, + "src/main/emulator/android/scrcpy-stream-session.test.ts": 419, + "src/main/emulator/android/scrcpy-video-frame-parser.test.ts": 162, + "src/main/emulator/android/uiautomator-tree.test.ts": 63, + "src/main/emulator/backends/android-emulator-backend.test.ts": 220, + "src/main/emulator/backends/ios-emulator-backend.test.ts": 74, + "src/main/emulator/emulator-availability.test.ts": 57, + "src/main/emulator/emulator-bridge.test.ts": 335, + "src/main/emulator/emulator-gesture-sender.test.ts": 191, + "src/main/emulator/emulator-session-registry.test.ts": 62, + "src/main/emulator/emulator-start-lease-registry.test.ts": 111, + "src/main/emulator/mjpeg-frame-parser.test.ts": 52, + "src/main/emulator/mjpeg-frame-stream.test.ts": 92, + "src/main/emulator/scrcpy-video-registry.test.ts": 51, + "src/main/emulator/serve-sim-accessibility-tree.test.ts": 74, + "src/main/emulator/serve-sim-ax-normalization.test.ts": 62, + "src/main/emulator/serve-sim-detached-session.test.ts": 72, + "src/main/emulator/serve-sim-execution.test.ts": 90, + "src/main/emulator/serve-sim-helper-processes.test.ts": 58, + "src/main/emulator/serve-sim-runtime-materializer.test.ts": 168, + "src/main/emulator/simctl-simulator-devices.test.ts": 66, + "src/main/ephemeral-vm-recipe-runner.test.ts": 625, + "src/main/ephemeral-vm-resume-integrity.test.ts": 271, + "src/main/ephemeral-vm-runtime-service.test.ts": 834, + "src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts": 272, + "src/main/execution-host-workspace-trust.test.ts": 164, + "src/main/external-editor-launch.test.ts": 121, + "src/main/fish-history-session.test.ts": 88, + "src/main/fish-xdg-data-dirs-handoff.test.ts": 66, + "src/main/fish-xdg-data-dirs-no-config-launch.test.ts": 301, + "src/main/folder-upgrade-worktree-path.test.ts": 57, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts": 768, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts": 245, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts": 258, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts": 53, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts": 2385, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.test.ts": 49, + "src/main/foreign-sqlite-readers/readers/cursor-profile.test.ts": 517, + "src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts": 131, + "src/main/gemini/hook-service.test.ts": 87, + "src/main/ghostty/discovery.test.ts": 78, + "src/main/ghostty/index.test.ts": 118, + "src/main/ghostty/mapper-extended.test.ts": 82, + "src/main/ghostty/mapper.test.ts": 85, + "src/main/ghostty/parser.test.ts": 55, + "src/main/ghostty/theme-import.test.ts": 108, + "src/main/ghostty/theme-resolution.test.ts": 71, + "src/main/git-bash.test.ts": 61, + "src/main/git/add-sparse-worktree.test.ts": 219, + "src/main/git/admission-tier-plumbing.test.ts": 61, + "src/main/git/base-ref-search-selector.test.ts": 200, + "src/main/git/branch-rename.test.ts": 67, + "src/main/git/canonical-repo-key.test.ts": 174, + "src/main/git/check-ignored-paths.test.ts": 60, + "src/main/git/coalesced-probe.test.ts": 61, + "src/main/git/command-runner/gh-exec-file-deadline.test.ts": 151, + "src/main/git/command-runner/git-admission-output-parity.test.ts": 196, + "src/main/git/command-runner/git-admission-span.test.ts": 323, + "src/main/git/command-runner/git-admission-storm-measurement.test.ts": 940, + "src/main/git/command-runner/git-command-timeout-behavior.test.ts": 1957, + "src/main/git/command-runner/git-command-timeout.test.ts": 56, + "src/main/git/command-runner/git-exec-admission-exempt.test.ts": 124, + "src/main/git/command-runner/git-exec-admission-lifetime.test.ts": 665, + "src/main/git/command-runner/git-operation-executor.test.ts": 72, + "src/main/git/command-runner/git-spawn-admission-lifetime.test.ts": 170, + "src/main/git/command-runner/git-stream-admission-lifetime.test.ts": 263, + "src/main/git/command-runner/git-subprocess-admission.test.ts": 373, + "src/main/git/command-runner/hosted-cli-deadline-log.test.ts": 145, + "src/main/git/command-runner/spawned-command-tree-kill.test.ts": 74, + "src/main/git/command-runner/wsl-command-resolution-windows-binary.test.ts": 75, + "src/main/git/command-runner/wsl-host-failure.test.ts": 186, "src/main/git/commit-object-ref.test.ts": 46, - "src/main/git/commit.test.ts": 129, - "src/main/git/exact-ref-probe.test.ts": 45, - "src/main/git/fetch-error-classification.test.ts": 43, - "src/main/git/fork-remote-refspec.test.ts": 50, - "src/main/git/fork-remote-stale-branch-refspec.test.ts": 54, - "src/main/git/gh-rate-limit-breaker.test.ts": 80, - "src/main/git/git-capability-state.test.ts": 51, - "src/main/git/git-status-read-lease-owner.test.ts": 33, - "src/main/git/git-upstream-status-read-owner.test.ts": 40, - "src/main/git/git-username.test.ts": 173, - "src/main/git/hosted-remote-url.test.ts": 44, - "src/main/git/huge-folder-ignore.test.ts": 59, - "src/main/git/local-repo-ref-maintenance.test.ts": 298, - "src/main/git/pack-refs-lock-ownership.test.ts": 80, - "src/main/git/porcelain-v1-records.test.ts": 197, - "src/main/git/remote-name-listing.test.ts": 59, - "src/main/git/remote-ref-probe-cache.test.ts": 46, - "src/main/git/remote-url-probe.test.ts": 73, - "src/main/git/remote.test.ts": 564, - "src/main/git/remove-worktree-branch-cleanup.test.ts": 163, - "src/main/git/remove-worktree-clean-preflight.test.ts": 125, - "src/main/git/remove-worktree.test.ts": 159, - "src/main/git/repo-branch-conflict-batched-probe.test.ts": 73, - "src/main/git/repo-branch-conflict-real-git.test.ts": 351, - "src/main/git/repo-branch-conflict.test.ts": 214, - "src/main/git/repo-clone-path.test.ts": 50, - "src/main/git/repo-default-base-timeout.test.ts": 66, - "src/main/git/repo-default-remote.test.ts": 73, - "src/main/git/repo-detection.test.ts": 697, - "src/main/git/repo-ref-maintenance-real-git.test.ts": 1124, - "src/main/git/repo-remote-drift-real.test.ts": 320, - "src/main/git/repo-remote-drift.test.ts": 190, - "src/main/git/repo-search-ref-compat.test.ts": 93, - "src/main/git/repo-username.test.ts": 190, - "src/main/git/repo.test.ts": 2242, - "src/main/git/runner-command-exec.test.ts": 708, - "src/main/git/runner-gh-account-binding.test.ts": 199, - "src/main/git/runner-gh-host-args.test.ts": 184, - "src/main/git/runner-gh-rate-limit-breaker.test.ts": 204, - "src/main/git/runner-windows-host-environment.test.ts": 285, - "src/main/git/runner-wsl-direct-read.test.ts": 1893, - "src/main/git/runner-wsl-gh-fallback.test.ts": 924, - "src/main/git/runner-wsl-linked-gitdir-timeout.test.ts": 175, - "src/main/git/runner-wsl-linked-gitdir-windows.test.ts": 292, - "src/main/git/runner-wsl-login-shell-capture.test.ts": 125, - "src/main/git/runner-wsl-read-routing.test.ts": 169, - "src/main/git/runner.test.ts": 120, - "src/main/git/settled-diff-cache-bounds.test.ts": 66, - "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 218, - "src/main/git/source-control/resolve-git-dir.test.ts": 42, - "src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts": 171, - "src/main/git/status-branch-compare-real-ref.test.ts": 229, - "src/main/git/status-branch-compare.test.ts": 157, - "src/main/git/status-branch-line-total-exec-contract.test.ts": 1673, - "src/main/git/status-branch-line-total-real-git.test.ts": 1107, - "src/main/git/status-branch-line-total-relay-parity.test.ts": 590, - "src/main/git/status-conflict-operations.test.ts": 216, - "src/main/git/status-conflict-overlap.bench.test.ts": 272, - "src/main/git/status-cquoted-paths.test.ts": 237, - "src/main/git/status-diff-settled-cache.test.ts": 426, - "src/main/git/status-diff.test.ts": 235, - "src/main/git/status-discard-and-bulk-staging.test.ts": 300, - "src/main/git/status-discard-symlink.test.ts": 661, - "src/main/git/status-line-stats-host-paths.test.ts": 78, - "src/main/git/status-pathspec-literals.test.ts": 464, - "src/main/git/status-porcelain-parser.test.ts": 47, - "src/main/git/status-read-coalescing.test.ts": 223, - "src/main/git/status-shared-symlinks.test.ts": 803, - "src/main/git/status-submodule-path-cache.test.ts": 895, - "src/main/git/status-submodule.test.ts": 231, - "src/main/git/status-symlink-probe-budget.test.ts": 145, - "src/main/git/status-upstream-negative-cache.test.ts": 1303, - "src/main/git/status-upstream-probe-churn.test.ts": 169, + "src/main/git/commit.test.ts": 231, + "src/main/git/create-event-repo-probe.test.ts": 73, + "src/main/git/exact-ref-probe.test.ts": 65, + "src/main/git/fetch-error-classification.test.ts": 53, + "src/main/git/fork-remote-refspec.test.ts": 64, + "src/main/git/fork-remote-stale-branch-refspec.test.ts": 55, + "src/main/git/gh-rate-limit-breaker.test.ts": 70, + "src/main/git/git-capability-state.test.ts": 43, + "src/main/git/git-network-safety-real-git.test.ts": 642, + "src/main/git/git-status-read-lease-owner.test.ts": 68, + "src/main/git/git-upstream-status-read-owner.test.ts": 52, + "src/main/git/git-username.test.ts": 211, + "src/main/git/hosted-remote-url.test.ts": 48, + "src/main/git/huge-folder-ignore.test.ts": 63, + "src/main/git/local-repo-ref-maintenance-probe-retry.test.ts": 244, + "src/main/git/local-repo-ref-maintenance.test.ts": 339, + "src/main/git/pack-refs-lock-ownership.test.ts": 77, + "src/main/git/porcelain-v1-records.test.ts": 132, + "src/main/git/remote-name-listing.test.ts": 307, + "src/main/git/remote-ref-probe-cache.test.ts": 342, + "src/main/git/remote-url-probe.test.ts": 376, + "src/main/git/remote.test.ts": 345, + "src/main/git/remove-worktree-branch-cleanup.test.ts": 337, + "src/main/git/remove-worktree-clean-preflight.test.ts": 188, + "src/main/git/remove-worktree.test.ts": 240, + "src/main/git/repo-branch-conflict-batched-probe.test.ts": 83, + "src/main/git/repo-branch-conflict-real-git.test.ts": 238, + "src/main/git/repo-branch-conflict.test.ts": 235, + "src/main/git/repo-clone-path.test.ts": 63, + "src/main/git/repo-default-base-timeout.test.ts": 108, + "src/main/git/repo-default-remote.test.ts": 134, + "src/main/git/repo-detection-async.test.ts": 507, + "src/main/git/repo-detection-batching.test.ts": 351, + "src/main/git/repo-detection-command.test.ts": 42, + "src/main/git/repo-detection-failure-parity.test.ts": 158, + "src/main/git/repo-detection.test.ts": 501, + "src/main/git/repo-maintenance-admission.test.ts": 647, + "src/main/git/repo-pack-index-maintenance.test.ts": 156, + "src/main/git/repo-pack-index-state.test.ts": 61, + "src/main/git/repo-ref-maintenance-real-git.test.ts": 3586, + "src/main/git/repo-remote-drift-real.test.ts": 266, + "src/main/git/repo-remote-drift.test.ts": 241, + "src/main/git/repo-search-ref-compat.test.ts": 129, + "src/main/git/repo-username.test.ts": 240, + "src/main/git/repo.test.ts": 1920, + "src/main/git/runner-buffer-cancellation.test.ts": 291, + "src/main/git/runner-command-exec.test.ts": 653, + "src/main/git/runner-gh-account-binding.test.ts": 214, + "src/main/git/runner-gh-host-args.test.ts": 189, + "src/main/git/runner-gh-rate-limit-breaker.test.ts": 217, + "src/main/git/runner-network-ssh-policy.test.ts": 159, + "src/main/git/runner-windows-host-environment.test.ts": 249, + "src/main/git/runner-wsl-direct-read.test.ts": 1856, + "src/main/git/runner-wsl-gh-fallback.test.ts": 1079, + "src/main/git/runner-wsl-linked-gitdir-timeout.test.ts": 195, + "src/main/git/runner-wsl-linked-gitdir-windows.test.ts": 248, + "src/main/git/runner-wsl-login-shell-capture.test.ts": 184, + "src/main/git/runner-wsl-network-ssh-policy.test.ts": 197, + "src/main/git/runner-wsl-read-routing.test.ts": 183, + "src/main/git/runner.test.ts": 212, + "src/main/git/saved-clone-probe-budget.test.ts": 81, + "src/main/git/saved-clone-target.test.ts": 612, + "src/main/git/settled-diff-cache-bounds.test.ts": 76, + "src/main/git/source-control/blob-absence-real-git.test.ts": 447, + "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 192, + "src/main/git/source-control/resolve-git-dir.test.ts": 59, + "src/main/git/source-control/stage-worktree-changes.test.ts": 1305, + "src/main/git/source-control/staging-discard-index-safety.test.ts": 941, + "src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts": 193, + "src/main/git/status-branch-compare-real-ref.test.ts": 403, + "src/main/git/status-branch-compare.test.ts": 239, + "src/main/git/status-branch-line-total-exec-contract.test.ts": 892, + "src/main/git/status-branch-line-total-real-git.test.ts": 836, + "src/main/git/status-conflict-operations.test.ts": 163, + "src/main/git/status-cquoted-paths.test.ts": 374, + "src/main/git/status-diff-settled-cache.test.ts": 322, + "src/main/git/status-diff.test.ts": 260, + "src/main/git/status-discard-and-bulk-staging.test.ts": 296, + "src/main/git/status-discard-symlink.test.ts": 544, + "src/main/git/status-line-stats-host-paths.test.ts": 77, + "src/main/git/status-pathspec-literals.test.ts": 362, + "src/main/git/status-porcelain-parser.test.ts": 78, + "src/main/git/status-read-coalescing.test.ts": 218, + "src/main/git/status-shared-symlinks.test.ts": 579, + "src/main/git/status-submodule-path-cache.test.ts": 367, + "src/main/git/status-submodule.test.ts": 264, + "src/main/git/status-symlink-probe-budget.test.ts": 179, + "src/main/git/status-upstream-negative-cache.test.ts": 718, + "src/main/git/status-upstream-probe-churn.test.ts": 280, "src/main/git/status-upstream-ref.test.ts": 42, - "src/main/git/status-wsl-pathspecs.test.ts": 132, - "src/main/git/status.test.ts": 212, - "src/main/git/upstream-deferred-fork-remote-real.test.ts": 217, - "src/main/git/upstream.test.ts": 388, - "src/main/git/worktree-add-creation-config.test.ts": 237, - "src/main/git/worktree-add-local-base-refresh.test.ts": 281, - "src/main/git/worktree-add-local-base-suggestion.test.ts": 178, - "src/main/git/worktree-add-timeout-override.test.ts": 199, - "src/main/git/worktree-base-divergence-real-git.test.ts": 1614, - "src/main/git/worktree-base-divergence.test.ts": 93, - "src/main/git/worktree-base-ref-probe.test.ts": 38, - "src/main/git/worktree-common-dir-comparison.test.ts": 177, - "src/main/git/worktree-configured-paths-concurrency.test.ts": 86, - "src/main/git/worktree-create-admission-tier.test.ts": 245, - "src/main/git/worktree-create-git-executor-real-git.test.ts": 356, - "src/main/git/worktree-create-preparation-real-git.test.ts": 1406, - "src/main/git/worktree-create-preparation-real-wsl.test.ts": 271, - "src/main/git/worktree-created-description-real-git.test.ts": 1340, - "src/main/git/worktree-created-disk-witness.test.ts": 149, - "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 40, - "src/main/git/worktree-diff-stamp-host-paths.test.ts": 55, - "src/main/git/worktree-git-capabilities.test.ts": 248, - "src/main/git/worktree-graph-listing.test.ts": 205, - "src/main/git/worktree-include-file.test.ts": 104, - "src/main/git/worktree-list-paths.test.ts": 571, - "src/main/git/worktree-list-porcelain.test.ts": 163, - "src/main/git/worktree-listing-created-sparse-distro.test.ts": 291, - "src/main/git/worktree-listing-sparse-distro.test.ts": 188, - "src/main/git/worktree-move.test.ts": 195, - "src/main/git/worktree-mutation-route-invalidation.test.ts": 145, - "src/main/git/worktree-porcelain-parsing.test.ts": 210, - "src/main/git/worktree-posix-path-case-sensitivity.test.ts": 134, - "src/main/git/worktree-preparation-base-oid.test.ts": 99, - "src/main/git/worktree-preparation-cancel-latency.bench.test.ts": 248, - "src/main/git/worktree-removal-large-tree.bench.test.ts": 291, - "src/main/git/worktree-removal-real-git.test.ts": 5000, - "src/main/git/worktree-remove-branch-deletion.test.ts": 194, - "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 241, - "src/main/git/worktree-scan-cache-sharing.test.ts": 284, - "src/main/git/worktree-separate-git-dir.test.ts": 614, - "src/main/git/worktree-shared-directories.test.ts": 1301, - "src/main/git/worktree-sparse-checkout-cache.test.ts": 234, - "src/main/git/worktree-sparse-checkout.test.ts": 675, - "src/main/git/worktree-sparse-state-host-paths.test.ts": 252, - "src/main/git/worktree-symlink-detection.test.ts": 55, - "src/main/git/wsl-direct-git-read-commands.test.ts": 54, - "src/main/git/wsl-linked-worktree-git-route-invalidation.test.ts": 81, - "src/main/git/wsl-linked-worktree-git-routing.test.ts": 105, - "src/main/git/wsl-process-group-termination.test.ts": 44, - "src/main/gitea/client-scan-invalidation.test.ts": 181, - "src/main/gitea/client.test.ts": 192, - "src/main/gitea/pull-request-creation.test.ts": 228, - "src/main/gitea/pull-request-mappers.test.ts": 56, - "src/main/gitea/repository-ref.test.ts": 71, - "src/main/github/auth-diagnose.test.ts": 51, - "src/main/github/client-create-pr.test.ts": 356, - "src/main/github/client-file-viewed.test.ts": 430, - "src/main/github/client-issue-origin-preference.test.ts": 247, - "src/main/github/client-issue-source.test.ts": 1603, - "src/main/github/client-merge-queue-auto-merge.test.ts": 342, - "src/main/github/client-merged-pr-visibility.test.ts": 343, - "src/main/github/client-pr-branch-discovery.test.ts": 352, - "src/main/github/client-pr-check-details.test.ts": 228, - "src/main/github/client-pr-checks.test.ts": 383, - "src/main/github/client-pr-comment-reactions.test.ts": 281, - "src/main/github/client-pr-conflict-summary.test.ts": 374, - "src/main/github/client-pr-fallback-number.test.ts": 347, - "src/main/github/client-pr-linked-lookup.test.ts": 310, - "src/main/github/client-pr-local-runtime.test.ts": 291, - "src/main/github/client-pr-push-target.test.ts": 222, - "src/main/github/client-pr-state.test.ts": 355, - "src/main/github/client-rate-limit-block.test.ts": 298, - "src/main/github/client-ssh-provider-execution-boundary.test.ts": 341, - "src/main/github/client-stack-merge-guard.test.ts": 422, - "src/main/github/client-starred.test.ts": 335, - "src/main/github/client-tracked-upstream-fork-owner.test.ts": 366, - "src/main/github/client-tracked-upstream-snapshot.test.ts": 618, - "src/main/github/client-work-item-check-summary.test.ts": 193, - "src/main/github/client-work-items-query-paging.test.ts": 354, - "src/main/github/client-work-items.test.ts": 235, - "src/main/github/comment-reactions.test.ts": 28, - "src/main/github/conflict-summary.test.ts": 50, - "src/main/github/default-branch-stale-pr.test.ts": 295, - "src/main/github/gh-account-token.test.ts": 66, - "src/main/github/gh-capability-state.test.ts": 37, - "src/main/github/gh-utils-concurrency.test.ts": 95, - "src/main/github/gh-utils.test.ts": 172, - "src/main/github/github-api-repository-validation.test.ts": 33, - "src/main/github/github-api-repository.test.ts": 276, - "src/main/github/github-enterprise-repository.test.ts": 144, - "src/main/github/github-pr-stack.test.ts": 79, - "src/main/github/github-remote-identity-parsing.test.ts": 40, - "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 404, - "src/main/github/github-repository-identity.gh-account.test.ts": 164, - "src/main/github/github-repository-identity.signed-cache.test.ts": 171, - "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 171, - "src/main/github/issues.test.ts": 109, - "src/main/github/pr-head-tracking-ref.test.ts": 74, - "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 202, - "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 297, - "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 329, - "src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts": 302, - "src/main/github/pr-refresh-coordinator-refresh-events.test.ts": 331, - "src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts": 280, - "src/main/github/pr-refresh-error-classification.test.ts": 45, - "src/main/github/pr-refresh-queue-growth-bound.test.ts": 623, - "src/main/github/pr-refresh-validation-backoff.test.ts": 58, - "src/main/github/pr-review-comment-lines.test.ts": 36, - "src/main/github/pr-start-point-compare-base.test.ts": 76, + "src/main/git/status-wsl-pathspecs.test.ts": 185, + "src/main/git/status.test.ts": 246, + "src/main/git/upstream-deferred-fork-remote-real.test.ts": 243, + "src/main/git/upstream.test.ts": 381, + "src/main/git/worktree-add-creation-config.test.ts": 290, + "src/main/git/worktree-add-local-base-refresh.test.ts": 291, + "src/main/git/worktree-add-local-base-suggestion.test.ts": 274, + "src/main/git/worktree-add-pending-removal.test.ts": 239, + "src/main/git/worktree-add-timeout-override.test.ts": 318, + "src/main/git/worktree-base-divergence-real-git.test.ts": 781, + "src/main/git/worktree-base-divergence.test.ts": 107, + "src/main/git/worktree-base-ref-probe.test.ts": 66, + "src/main/git/worktree-base-refresh-contention.test.ts": 239, + "src/main/git/worktree-base-refresh-lock-real-git.test.ts": 1272, + "src/main/git/worktree-branch-removal-host.test.ts": 185, + "src/main/git/worktree-common-dir-comparison.test.ts": 273, + "src/main/git/worktree-configured-paths-concurrency.test.ts": 91, + "src/main/git/worktree-create-admission-tier.test.ts": 305, + "src/main/git/worktree-create-git-executor-real-git.test.ts": 477, + "src/main/git/worktree-create-preparation-real-git.test.ts": 1597, + "src/main/git/worktree-create-preparation-real-wsl.test.ts": 327, + "src/main/git/worktree-created-description-real-git.test.ts": 1158, + "src/main/git/worktree-created-disk-witness.test.ts": 218, + "src/main/git/worktree-delete-limit.test.ts": 44, + "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 62, + "src/main/git/worktree-diff-stamp-host-paths.test.ts": 60, + "src/main/git/worktree-git-capabilities.test.ts": 262, + "src/main/git/worktree-graph-listing.test.ts": 349, + "src/main/git/worktree-include-file.test.ts": 157, + "src/main/git/worktree-list-paths.test.ts": 464, + "src/main/git/worktree-list-porcelain.test.ts": 236, + "src/main/git/worktree-list-reader-cancellation.test.ts": 64, + "src/main/git/worktree-listing-created-sparse-distro.test.ts": 223, + "src/main/git/worktree-listing-sparse-cancellation.test.ts": 176, + "src/main/git/worktree-listing-sparse-distro.test.ts": 191, + "src/main/git/worktree-move.test.ts": 278, + "src/main/git/worktree-mutation-route-invalidation.test.ts": 177, + "src/main/git/worktree-porcelain-parsing.test.ts": 248, + "src/main/git/worktree-posix-path-case-sensitivity.test.ts": 169, + "src/main/git/worktree-preparation-add.test.ts": 194, + "src/main/git/worktree-preparation-base-oid.test.ts": 239, + "src/main/git/worktree-preparation-cancel-latency.bench.test.ts": 286, + "src/main/git/worktree-preparation-discard-real-git.test.ts": 466, + "src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts": 485, + "src/main/git/worktree-preparation-lock-real-git.test.ts": 922, + "src/main/git/worktree-preparation-lock.test.ts": 87, + "src/main/git/worktree-preparation-tip-refresh-real-git.test.ts": 488, + "src/main/git/worktree-preparation-tip-refresh.test.ts": 108, + "src/main/git/worktree-rebase-update-refs-real-git.test.ts": 273, + "src/main/git/worktree-removal-large-tree.bench.test.ts": 284, + "src/main/git/worktree-removal-real-git.test.ts": 834, + "src/main/git/worktree-remove-branch-deletion.test.ts": 258, + "src/main/git/worktree-safety-real-git.test.ts": 989, + "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 312, + "src/main/git/worktree-scan-cache-sharing.test.ts": 312, + "src/main/git/worktree-separate-git-dir.test.ts": 543, + "src/main/git/worktree-shared-directories.test.ts": 911, + "src/main/git/worktree-sparse-checkout-cache.test.ts": 206, + "src/main/git/worktree-sparse-checkout.test.ts": 574, + "src/main/git/worktree-sparse-state-host-paths.test.ts": 264, + "src/main/git/worktree-symlink-detection.test.ts": 61, + "src/main/git/wsl-direct-git-read-commands.test.ts": 72, + "src/main/git/wsl-linked-worktree-git-route-invalidation.test.ts": 61, + "src/main/git/wsl-linked-worktree-git-routing.test.ts": 118, + "src/main/git/wsl-process-group-termination.test.ts": 57, + "src/main/gitea/client-scan-invalidation.test.ts": 377, + "src/main/gitea/client.test.ts": 413, + "src/main/gitea/pull-request-creation.test.ts": 395, + "src/main/gitea/pull-request-mappers.test.ts": 44, + "src/main/gitea/repository-ref.test.ts": 373, + "src/main/github/auth-diagnose.test.ts": 66, + "src/main/github/client-create-pr.test.ts": 312, + "src/main/github/client-file-viewed.test.ts": 465, + "src/main/github/client-issue-origin-preference.test.ts": 405, + "src/main/github/client-issue-source.test.ts": 849, + "src/main/github/client-merge-queue-auto-merge.test.ts": 437, + "src/main/github/client-merged-pr-visibility.test.ts": 487, + "src/main/github/client-pr-branch-discovery.test.ts": 441, + "src/main/github/client-pr-check-details.test.ts": 427, + "src/main/github/client-pr-checks.test.ts": 464, + "src/main/github/client-pr-comment-reactions.test.ts": 449, + "src/main/github/client-pr-conflict-summary.test.ts": 478, + "src/main/github/client-pr-fallback-number.test.ts": 442, + "src/main/github/client-pr-linked-lookup.test.ts": 385, + "src/main/github/client-pr-local-runtime.test.ts": 511, + "src/main/github/client-pr-lookup-coalescing.test.ts": 622, + "src/main/github/client-pr-push-target.test.ts": 427, + "src/main/github/client-pr-state.test.ts": 408, + "src/main/github/client-rate-limit-block.test.ts": 377, + "src/main/github/client-ssh-provider-execution-boundary.test.ts": 441, + "src/main/github/client-stack-merge-guard.test.ts": 456, + "src/main/github/client-starred.test.ts": 340, + "src/main/github/client-tracked-upstream-fork-owner.test.ts": 438, + "src/main/github/client-tracked-upstream-snapshot.test.ts": 1192, + "src/main/github/client-work-item-check-summary.test.ts": 444, + "src/main/github/client-work-items-query-paging.test.ts": 651, + "src/main/github/client-work-items.test.ts": 579, + "src/main/github/comment-reactions.test.ts": 74, + "src/main/github/conflict-summary.test.ts": 97, + "src/main/github/default-branch-stale-pr.test.ts": 378, + "src/main/github/gh-account-token.test.ts": 60, + "src/main/github/gh-capability-state.test.ts": 48, + "src/main/github/gh-utils-concurrency.test.ts": 434, + "src/main/github/gh-utils.test.ts": 529, + "src/main/github/github-api-repository-validation.test.ts": 50, + "src/main/github/github-api-repository.test.ts": 563, + "src/main/github/github-enterprise-repository.test.ts": 403, + "src/main/github/github-pr-stack.test.ts": 376, + "src/main/github/github-remote-identity-parsing.test.ts": 58, + "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 538, + "src/main/github/github-repository-identity.gh-account.test.ts": 408, + "src/main/github/github-repository-identity.signed-cache.test.ts": 372, + "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 620, + "src/main/github/issue-mutation-source.test.ts": 436, + "src/main/github/issues.test.ts": 479, + "src/main/github/pr-head-tracking-ref.test.ts": 67, + "src/main/github/pr-mutation-review-invalidation.test.ts": 2411, + "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 660, + "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 575, + "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 859, + "src/main/github/pr-refresh-coordinator-completion-order.test.ts": 602, + "src/main/github/pr-refresh-coordinator-error-cadence.test.ts": 574, + "src/main/github/pr-refresh-coordinator-foreground-pacing.test.ts": 469, + "src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts": 858, + "src/main/github/pr-refresh-coordinator-refresh-events.test.ts": 726, + "src/main/github/pr-refresh-coordinator-visibility-policy.test.ts": 1217, + "src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts": 935, + "src/main/github/pr-refresh-error-classification.test.ts": 60, + "src/main/github/pr-refresh-queue-growth-bound.test.ts": 1057, + "src/main/github/pr-refresh-validation-backoff.test.ts": 56, + "src/main/github/pr-review-comment-lines.test.ts": 39, + "src/main/github/pr-start-point-compare-base.test.ts": 64, "src/main/github/pr-start-point.test.ts": 95, - "src/main/github/project-view-host-auth.test.ts": 116, - "src/main/github/project-view.test.ts": 113, - "src/main/github/project-view/mutations.test.ts": 57, - "src/main/github/project-view/project-field-mutations.test.ts": 58, - "src/main/github/project-view/project-view-table.test.ts": 175, - "src/main/github/project-view/repository-field-options.test.ts": 44, - "src/main/github/rate-limit.test.ts": 68, - "src/main/github/review-head-remote.test.ts": 54, - "src/main/github/stacked-pr-creation.test.ts": 62, - "src/main/github/work-item-details-api-parity.test.ts": 295, - "src/main/github/work-item-details-concurrency.test.ts": 50, - "src/main/github/work-item-details-enterprise-host.test.ts": 203, - "src/main/github/work-item-details-file-viewed.test.ts": 236, - "src/main/github/work-item-details-pr-files.test.ts": 225, - "src/main/github/work-item-details.test.ts": 249, - "src/main/github/work-item-search-fallback-environment.test.ts": 208, - "src/main/github/work-item-search-freshness.test.ts": 1026, - "src/main/github/work-item-search-isolation.test.ts": 434, - "src/main/github/work-item-search-pagination.test.ts": 284, - "src/main/github/work-item-search-semantics.test.ts": 372, - "src/main/gitlab/client-mr-auth-rate-limit.test.ts": 142, - "src/main/gitlab/client-mr-branch-lookup.test.ts": 184, - "src/main/gitlab/client-mr-job-ci.test.ts": 83, - "src/main/gitlab/client-mr-listing.test.ts": 138, - "src/main/gitlab/client-mr-review-actions.test.ts": 154, - "src/main/gitlab/client-work-items.test.ts": 230, - "src/main/gitlab/client.test.ts": 260, - "src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts": 180, - "src/main/gitlab/gitlab-known-host-probe.test.ts": 71, - "src/main/gitlab/gitlab-known-host-retirement.test.ts": 235, - "src/main/gitlab/gl-utils.test.ts": 120, - "src/main/gitlab/issues.test.ts": 257, - "src/main/gitlab/mappers-workitem.test.ts": 62, - "src/main/gitlab/mappers.test.ts": 68, - "src/main/gitlab/merge-request-creation.test.ts": 70, - "src/main/gitlab/mr-head-tracking-ref.test.ts": 75, - "src/main/gitlab/project-ref-parser.test.ts": 58, - "src/main/gitlab/work-item-details.test.ts": 114, - "src/main/global-fetch-call-site-audit.test.ts": 314, - "src/main/grok-accounts/status.test.ts": 56, - "src/main/grok/grok-hook-config-file.test.ts": 123, - "src/main/grok/grok-hook-owners.test.ts": 97, - "src/main/grok/grok-hook-remnant-removal.test.ts": 116, - "src/main/grok/grok-hook-stdin-no-eof.test.ts": 472, - "src/main/grok/hook-service.test.ts": 174, - "src/main/grok/windows-grok-hook-script.test.ts": 133, - "src/main/grok/windows-hook-launcher-chain.test.ts": 97, - "src/main/hang-watchdog/hang-detection-marker.test.ts": 43, - "src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts": 61, - "src/main/hang-watchdog/hang-watchdog-worker-path.test.ts": 59, - "src/main/hang-watchdog/main-thread-hang-telemetry.test.ts": 175, - "src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts": 58, - "src/main/hang-watchdog/main-thread-hang-watchdog.test.ts": 78, - "src/main/headless-automation-dispatcher-source-boundary.test.ts": 39, - "src/main/hermes/hook-service.test.ts": 434, - "src/main/hook-archive-timeout-observation.test.ts": 1092, - "src/main/hook-termination-real-process.test.ts": 8201, - "src/main/hooks-archive-exit-observation.test.ts": 187, - "src/main/hooks-effective-hook-resolution.test.ts": 209, - "src/main/hooks-issue-command.test.ts": 340, - "src/main/hooks-orca-yaml-parsing.test.ts": 201, - "src/main/hooks-runner.test.ts": 197, - "src/main/hooks-setup-runner-script.test.ts": 219, - "src/main/hooks.test.ts": 344, - "src/main/host-tree-removal-asar.electron.test.ts": 359, - "src/main/host/deferred-secret-protection-report.test.ts": 51, - "src/main/host/electron-secret-store.test.ts": 66, - "src/main/host/secret-protection-report.test.ts": 74, - "src/main/i18n/main-i18n-lazy-locale.test.ts": 323, - "src/main/ipc/agent-hooks.test.ts": 661, - "src/main/ipc/agent-pane-authority-ownership.test.ts": 31, - "src/main/ipc/agent-pane-retirement-ipc.test.ts": 74, - "src/main/ipc/ai-vault-scan-coalescing.test.ts": 824, - "src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts": 1337, - "src/main/ipc/ai-vault-search-all-hosts.test.ts": 194, - "src/main/ipc/ai-vault-search-merged-cursor.test.ts": 135, - "src/main/ipc/ai-vault-search.test.ts": 517, - "src/main/ipc/ai-vault.test.ts": 1998, - "src/main/ipc/app.test.ts": 458, - "src/main/ipc/automations-external-scope.test.ts": 184, - "src/main/ipc/bounded-warning-dedupe.test.ts": 35, - "src/main/ipc/browser-client-page-metadata-ipc.test.ts": 144, - "src/main/ipc/browser-preview-tool-authorization.test.ts": 582, - "src/main/ipc/browser-session-profile-ipc.test.ts": 227, - "src/main/ipc/browser-tab-registration-wait.test.ts": 133, - "src/main/ipc/browser.test.ts": 824, - "src/main/ipc/cli-appimage-stale-registration.test.ts": 285, - "src/main/ipc/cli.test.ts": 162, - "src/main/ipc/codex-config-sync.test.ts": 46, - "src/main/ipc/command-path-resolver.test.ts": 39, - "src/main/ipc/computer-use-permissions.test.ts": 73, - "src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts": 100, - "src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts": 67, - "src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts": 56, - "src/main/ipc/crash-reporting.test.ts": 155, - "src/main/ipc/created-worktree-reconciliation.test.ts": 118, - "src/main/ipc/created-worktree-root-prune.test.ts": 307, - "src/main/ipc/dashboard-payload-validation.test.ts": 241, - "src/main/ipc/dashboard-popout.test.ts": 97, - "src/main/ipc/deferred-emoji-shortcode-dataset.test.ts": 94, - "src/main/ipc/desktop-renderer-runtime-capabilities.test.ts": 597, - "src/main/ipc/developer-permissions.test.ts": 132, - "src/main/ipc/diagnostics.test.ts": 78, - "src/main/ipc/doc-preview-grant-ipc.test.ts": 92, - "src/main/ipc/dropped-path-resolution.test.ts": 81, - "src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts": 139, - "src/main/ipc/emulator-renderer-lifetime.test.ts": 113, - "src/main/ipc/emulator-stream-listener-cleanup.test.ts": 52, - "src/main/ipc/ephemeral-vm-provision-cancel.test.ts": 2605, - "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts": 1162, - "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts": 816, - "src/main/ipc/ephemeral-vm.test.ts": 1095, - "src/main/ipc/feedback-image-attachments.test.ts": 78, - "src/main/ipc/feedback.test.ts": 175, - "src/main/ipc/filesystem-allowed-roots.test.ts": 437, - "src/main/ipc/filesystem-auth.test.ts": 1145, - "src/main/ipc/filesystem-branch-compare-diff.test.ts": 2519, - "src/main/ipc/filesystem-commit-message-generation.test.ts": 3728, - "src/main/ipc/filesystem-commit-message-model-discovery.test.ts": 4612, - "src/main/ipc/filesystem-conflict-operation-routing.test.ts": 3748, - "src/main/ipc/filesystem-download-lifetime.test.ts": 673, - "src/main/ipc/filesystem-download-transfers.test.ts": 4793, - "src/main/ipc/filesystem-git-commit-dispatch.test.ts": 2595, - "src/main/ipc/filesystem-git-status-staging.test.ts": 3013, - "src/main/ipc/filesystem-import-local-rollback.test.ts": 86, - "src/main/ipc/filesystem-import-ssh-ops.test.ts": 545, - "src/main/ipc/filesystem-import-ssh-path-safety.test.ts": 94, - "src/main/ipc/filesystem-import-ssh.test.ts": 490, - "src/main/ipc/filesystem-import.test.ts": 6057, - "src/main/ipc/filesystem-list-files-handler-name-filter.test.ts": 3031, - "src/main/ipc/filesystem-list-files-name-filter.test.ts": 314, - "src/main/ipc/filesystem-list-files.test.ts": 320, - "src/main/ipc/filesystem-markdown-document-listing.test.ts": 3120, - "src/main/ipc/filesystem-mutations-runtime-upload.test.ts": 2064, - "src/main/ipc/filesystem-mutations.test.ts": 3581, - "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts": 32, - "src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts": 218, - "src/main/ipc/filesystem-pull-request-field-generation.test.ts": 3228, - "src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts": 2896, - "src/main/ipc/filesystem-runtime-upload-staging.test.ts": 140, - "src/main/ipc/filesystem-search-file-paths.test.ts": 426, - "src/main/ipc/filesystem-search-rg-timeout.test.ts": 1022, - "src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 435, - "src/main/ipc/filesystem-watcher-document-lifetime.test.ts": 309, - "src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 188, - "src/main/ipc/filesystem-watcher-event-batch.test.ts": 38, - "src/main/ipc/filesystem-watcher-ignore.test.ts": 53, - "src/main/ipc/filesystem-watcher-large-batch.test.ts": 423, - "src/main/ipc/filesystem-watcher-local-events.test.ts": 482, - "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 1128, - "src/main/ipc/filesystem-watcher-native-capacity.test.ts": 198, - "src/main/ipc/filesystem-watcher-real.test.ts": 699, - "src/main/ipc/filesystem-watcher-remote-batch.test.ts": 127, - "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 457, - "src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 188, - "src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 133, - "src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 397, - "src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 353, - "src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 125, - "src/main/ipc/filesystem-watcher-wsl.test.ts": 90, - "src/main/ipc/filesystem-watcher.test.ts": 2230, - "src/main/ipc/filesystem.test.ts": 2871, - "src/main/ipc/floating-workspace-directory.test.ts": 82, - "src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts": 103, - "src/main/ipc/folder-repo-git-upgrade.test.ts": 2578, - "src/main/ipc/git-status-upstream-ref-watch-request.test.ts": 58, - "src/main/ipc/github-ipc-channel-parity.test.ts": 396, - "src/main/ipc/github-issue-source-preference.test.ts": 273, - "src/main/ipc/github-pr-refresh-routing.test.ts": 206, - "src/main/ipc/github-repo-access-guards.test.ts": 398, - "src/main/ipc/github-ssh-connection-routing.test.ts": 340, - "src/main/ipc/github-star-telemetry.test.ts": 319, - "src/main/ipc/github-work-item-args.test.ts": 36, - "src/main/ipc/github-wsl-runtime-routing.test.ts": 360, - "src/main/ipc/gitlab-repo-access.test.ts": 84, - "src/main/ipc/gitlab.test.ts": 122, - "src/main/ipc/hooks/worktree-hook-execution-host.test.ts": 516, - "src/main/ipc/hosted-review.test.ts": 419, - "src/main/ipc/jira-cancellable-requests.test.ts": 55, - "src/main/ipc/keybindings.test.ts": 46, - "src/main/ipc/linear.test.ts": 72, - "src/main/ipc/local-log-tail-lifetime.test.ts": 78, - "src/main/ipc/local-log-tail.test.ts": 63, - "src/main/ipc/local-network-connection-test.test.ts": 221, - "src/main/ipc/local-worktree-runtime-options.test.ts": 46, - "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts": 46, - "src/main/ipc/macos-keyboard-layout-snapshot.test.ts": 50, - "src/main/ipc/markdown-documents.test.ts": 34, - "src/main/ipc/minimax-credentials.test.ts": 94, - "src/main/ipc/mobile.test.ts": 15914, - "src/main/ipc/native-chat-renderer-lifetime.test.ts": 57, - "src/main/ipc/native-chat-renderer-watcher-cleanup.test.ts": 327, - "src/main/ipc/native-chat-subscribe-lifecycle.test.ts": 209, - "src/main/ipc/native-chat.test.ts": 1810, - "src/main/ipc/notebook.test.ts": 102, - "src/main/ipc/notifications-custom-sound.test.ts": 167, - "src/main/ipc/notifications-delivery-gating.test.ts": 226, - "src/main/ipc/notifications-message-formatting.test.ts": 81, - "src/main/ipc/notifications-mobile-fanout.test.ts": 141, - "src/main/ipc/notifications-permission-onboarding.test.ts": 216, - "src/main/ipc/notifications-retention-lifecycle.test.ts": 378, - "src/main/ipc/notifications-subject-retirement.test.ts": 56, - "src/main/ipc/orca-profile-auth-handlers.test.ts": 461, - "src/main/ipc/orca-profile-org-members-handlers.test.ts": 57, - "src/main/ipc/orca-profiles-switch-persistence.test.ts": 1121, - "src/main/ipc/orca-profiles.test.ts": 419, - "src/main/ipc/parcel-watcher-child-launch.test.ts": 73, - "src/main/ipc/parcel-watcher-child-registry.test.ts": 91, - "src/main/ipc/parcel-watcher-crash-fuse.test.ts": 42, - "src/main/ipc/parcel-watcher-disconnect-termination.test.ts": 97, - "src/main/ipc/parcel-watcher-entry-path.test.ts": 36, - "src/main/ipc/parcel-watcher-event-cancellation.test.ts": 61, - "src/main/ipc/parcel-watcher-event-delivery.test.ts": 37, - "src/main/ipc/parcel-watcher-module-loader.test.ts": 39, - "src/main/ipc/parcel-watcher-process-entry.test.ts": 234, - "src/main/ipc/parcel-watcher-process.test.ts": 678, - "src/main/ipc/parcel-watcher-root-path-rewrite.test.ts": 88, - "src/main/ipc/parcel-watcher-shallow-subscription.test.ts": 54, - "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts": 254, - "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts": 244, - "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts": 113, - "src/main/ipc/pet-bundle.test.ts": 60, - "src/main/ipc/pet.test.ts": 252, - "src/main/ipc/plugin-marketplaces.test.ts": 227, - "src/main/ipc/plugins.test.ts": 258, - "src/main/ipc/preflight-agent-detection-no-subprocess.test.ts": 249, - "src/main/ipc/preflight-agent-detection.test.ts": 212, - "src/main/ipc/preflight-agent-refresh.test.ts": 190, - "src/main/ipc/preflight-command-exec.test.ts": 59, - "src/main/ipc/preflight-host-cli-status.test.ts": 311, - "src/main/ipc/preflight-remote-ssh.test.ts": 137, - "src/main/ipc/preflight-windows-path-refresh.repro.test.ts": 69, - "src/main/ipc/preflight-wsl-agent-detection.test.ts": 151, - "src/main/ipc/preflight-wsl-command.test.ts": 43, - "src/main/ipc/pty-activation-inventory-scope.test.ts": 1650, - "src/main/ipc/pty-applied-size-reporting.test.ts": 1519, - "src/main/ipc/pty-buffer-snapshot-dispatch.test.ts": 1738, - "src/main/ipc/pty-codex-account-attribution.test.ts": 1477, - "src/main/ipc/pty-controller-owner-recovery.test.ts": 1749, - "src/main/ipc/pty-controller-ownership-routing.test.ts": 1531, - "src/main/ipc/pty-controller-process-inventory.test.ts": 1860, - "src/main/ipc/pty-controller-spawn-admission.test.ts": 3580, - "src/main/ipc/pty-cumulative-ack-accounting.test.ts": 1219, - "src/main/ipc/pty-daemon-controller-teardown.test.ts": 2798, - "src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts": 2463, - "src/main/ipc/pty-daemon-spawn-codex-auth.test.ts": 1802, - "src/main/ipc/pty-daemon-spawn-session-identity.test.ts": 2434, - "src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts": 2656, - "src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts": 2110, - "src/main/ipc/pty-dead-owner-respawn.test.ts": 1289, - "src/main/ipc/pty-delivery-health-heal.test.ts": 1942, - "src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts": 1892, - "src/main/ipc/pty-encoding.test.ts": 26, - "src/main/ipc/pty-global-renderer-credit.test.ts": 1376, - "src/main/ipc/pty-hidden-at-spawn-mark.test.ts": 3652, - "src/main/ipc/pty-hidden-delivery-gate.test.ts": 57, - "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts": 3043, - "src/main/ipc/pty-ipc-producer-flow-control.test.ts": 1520, - "src/main/ipc/pty-listener-teardown-and-orphans.test.ts": 3968, - "src/main/ipc/pty-login-shell-startup-commands.test.ts": 1723, - "src/main/ipc/pty-management.test.ts": 102, - "src/main/ipc/pty-output-batching-drain.test.ts": 1696, - "src/main/ipc/pty-output-drain-rounds.test.ts": 1804, - "src/main/ipc/pty-pane-claim-arbitration.test.ts": 4553, - "src/main/ipc/pty-pane-materialization-race.test.ts": 2447, - "src/main/ipc/pty-pane-reservation-settlement.test.ts": 1450, - "src/main/ipc/pty-pane-restart-replace.test.ts": 1575, - "src/main/ipc/pty-pending-data-drain-queue-differential.test.ts": 2040, - "src/main/ipc/pty-pending-data-drain-queue.test.ts": 59, - "src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts": 99, - "src/main/ipc/pty-pending-output-cap.test.ts": 1823, - "src/main/ipc/pty-pending-projection-admissions.test.ts": 95, - "src/main/ipc/pty-persisted-incarnation-repair.test.ts": 940, - "src/main/ipc/pty-producer-flow-control.test.ts": 44, - "src/main/ipc/pty-renderer-inflight-credit.test.ts": 1965, - "src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts": 1732, - "src/main/ipc/pty-renderer-liveness-guard.test.ts": 1889, - "src/main/ipc/pty-renderer-send-failure-recovery.test.ts": 1697, - "src/main/ipc/pty-restore-record-seeding.test.ts": 4503, - "src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts": 1001, - "src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts": 2902, - "src/main/ipc/pty-runtime-kill-and-exit.test.ts": 1637, - "src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts": 1369, - "src/main/ipc/pty-serializer-settlement-mapping.test.ts": 4239, - "src/main/ipc/pty-session-liveness-and-ownership.test.ts": 1832, - "src/main/ipc/pty-spawn-codex-home-unavailable.test.ts": 880, - "src/main/ipc/pty-spawn-cwd-fallback.test.ts": 2294, - "src/main/ipc/pty-spawn-env-agent-overlays.test.ts": 1925, - "src/main/ipc/pty-spawn-env-codex-home-routing.test.ts": 1578, - "src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts": 1188, - "src/main/ipc/pty-spawn-env-terminal-basics.test.ts": 2674, - "src/main/ipc/pty-spawn-pi-owner-env.test.ts": 2317, - "src/main/ipc/pty-spawn-runtime-handle-binding.test.ts": 2425, - "src/main/ipc/pty-spawn-timing.test.ts": 74, - "src/main/ipc/pty-ssh-stop-verdict.test.ts": 1688, - "src/main/ipc/pty-ssh-undelivered-kill.test.ts": 1930, - "src/main/ipc/pty-startup-barrier-and-listing.test.ts": 1269, - "src/main/ipc/pty-startup-barrier-ordering.test.ts": 32, - "src/main/ipc/pty-startup-swap-window-presence.test.ts": 1724, - "src/main/ipc/pty-windows-shell-selection.test.ts": 1559, - "src/main/ipc/pty-write-ipc-validation.test.ts": 1475, - "src/main/ipc/pty-wsl-cwd-validation.test.ts": 1804, - "src/main/ipc/pty/configured-terminal-shell-args.test.ts": 52, - "src/main/ipc/pty/daemon-late-exit.test.ts": 3152, - "src/main/ipc/pty/delivery/attached-pty-size.test.ts": 36, - "src/main/ipc/pty/host-env/omp-launch-environment.test.ts": 84, - "src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts": 1617, - "src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts": 672, - "src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts": 811, - "src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts": 67, - "src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts": 814, - "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts": 66, - "src/main/ipc/pty/pane/launch-authority.test.ts": 82, - "src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts": 2925, - "src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts": 692, - "src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts": 127, + "src/main/github/project-view-host-auth.test.ts": 102, + "src/main/github/project-view.test.ts": 482, + "src/main/github/project-view/mutations.test.ts": 63, + "src/main/github/project-view/project-field-mutations.test.ts": 51, + "src/main/github/project-view/project-view-config.test.ts": 427, + "src/main/github/project-view/project-view-table.test.ts": 461, + "src/main/github/project-view/repository-field-options.test.ts": 51, + "src/main/github/rate-limit.test.ts": 88, + "src/main/github/review-head-remote.test.ts": 60, + "src/main/github/stacked-pr-creation.test.ts": 437, + "src/main/github/work-item-details-concurrency.test.ts": 71, + "src/main/github/work-item-details-config-change.test.ts": 426, + "src/main/github/work-item-details-enterprise-host.test.ts": 450, + "src/main/github/work-item-details-file-viewed.test.ts": 468, + "src/main/github/work-item-details-issue-source.test.ts": 72, + "src/main/github/work-item-details-pr-files.test.ts": 418, + "src/main/github/work-item-details.test.ts": 472, + "src/main/github/work-item-search-fallback-environment.test.ts": 603, + "src/main/github/work-item-search-freshness.test.ts": 1299, + "src/main/github/work-item-search-isolation.test.ts": 679, + "src/main/github/work-item-search-pagination.test.ts": 501, + "src/main/github/work-item-search-semantics.test.ts": 638, + "src/main/gitlab/client-mr-auth-rate-limit.test.ts": 411, + "src/main/gitlab/client-mr-branch-lookup.test.ts": 473, + "src/main/gitlab/client-mr-job-ci.test.ts": 408, + "src/main/gitlab/client-mr-listing.test.ts": 409, + "src/main/gitlab/client-mr-review-actions.test.ts": 387, + "src/main/gitlab/client-work-items.test.ts": 451, + "src/main/gitlab/client.test.ts": 456, + "src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts": 375, + "src/main/gitlab/gitlab-known-host-probe.test.ts": 383, + "src/main/gitlab/gitlab-known-host-retirement.test.ts": 728, + "src/main/gitlab/gl-utils.test.ts": 452, + "src/main/gitlab/issues.test.ts": 490, + "src/main/gitlab/mappers-workitem.test.ts": 45, + "src/main/gitlab/mappers.test.ts": 71, + "src/main/gitlab/merge-request-creation.test.ts": 446, + "src/main/gitlab/mr-head-tracking-ref.test.ts": 61, + "src/main/gitlab/project-ref-parser.test.ts": 60, + "src/main/gitlab/work-item-details.test.ts": 100, + "src/main/grok-accounts/status.test.ts": 69, + "src/main/grok/grok-hook-config-file.test.ts": 153, + "src/main/grok/grok-hook-owners.test.ts": 61, + "src/main/grok/grok-hook-remnant-removal.test.ts": 89, + "src/main/grok/grok-hook-stdin-no-eof.test.ts": 465, + "src/main/grok/hook-service.test.ts": 154, + "src/main/grok/windows-grok-hook-script.test.ts": 87, + "src/main/grok/windows-hook-launcher-chain.test.ts": 104, + "src/main/hang-watchdog/hang-detection-marker.test.ts": 71, + "src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts": 64, + "src/main/hang-watchdog/hang-watchdog-worker-path.test.ts": 53, + "src/main/hang-watchdog/main-thread-hang-telemetry.test.ts": 299, + "src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts": 107, + "src/main/hang-watchdog/main-thread-hang-watchdog.test.ts": 134, + "src/main/hang-watchdog/previous-hang-detection.test.ts": 118, + "src/main/hermes/hermes-config-comment-boundaries.test.ts": 513, + "src/main/hermes/hermes-config-document.test.ts": 197, + "src/main/hermes/hermes-config-flow-insertion.test.ts": 417, + "src/main/hermes/hermes-config-preservation.test.ts": 197, + "src/main/hermes/hermes-config-remote-adapter.test.ts": 186, + "src/main/hermes/hermes-config-source-edits.test.ts": 275, + "src/main/hermes/hook-service.test.ts": 307, + "src/main/hook-archive-timeout-observation.test.ts": 1099, + "src/main/hook-termination-real-process.test.ts": 8247, + "src/main/hooks-archive-exit-observation.test.ts": 277, + "src/main/hooks-effective-hook-resolution.test.ts": 471, + "src/main/hooks-issue-command.test.ts": 500, + "src/main/hooks-orca-yaml-parsing.test.ts": 485, + "src/main/hooks-runner.test.ts": 274, + "src/main/hooks-setup-runner-script.test.ts": 238, + "src/main/hooks.test.ts": 493, + "src/main/host-tree-removal-asar.electron.test.ts": 259, + "src/main/host/deferred-secret-protection-report.test.ts": 99, + "src/main/host/electron-secret-store.test.ts": 91, + "src/main/host/secret-protection-report.test.ts": 79, + "src/main/host/structured-attention-localization.test.ts": 333, + "src/main/i18n/main-i18n-lazy-locale.test.ts": 278, + "src/main/ipc/agent-hooks.test.ts": 733, + "src/main/ipc/agent-pane-authority-ownership.test.ts": 56, + "src/main/ipc/agent-pane-retirement-ipc.test.ts": 62, + "src/main/ipc/agent-session-attachment-upload.test.ts": 7519, + "src/main/ipc/ai-vault-host-leg-cache-retention.test.ts": 553, + "src/main/ipc/ai-vault-scan-coalescing.test.ts": 1267, + "src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts": 1776, + "src/main/ipc/ai-vault-search-all-hosts.test.ts": 260, + "src/main/ipc/ai-vault-search-merged-cursor.test.ts": 223, + "src/main/ipc/ai-vault-search.test.ts": 1049, + "src/main/ipc/ai-vault-unresolved-owner.test.ts": 589, + "src/main/ipc/ai-vault.test.ts": 847, + "src/main/ipc/app.test.ts": 585, + "src/main/ipc/automations-external-scope.test.ts": 481, + "src/main/ipc/bounded-warning-dedupe.test.ts": 45, + "src/main/ipc/browser-client-page-metadata-ipc.test.ts": 222, + "src/main/ipc/browser-preview-tool-authorization.test.ts": 669, + "src/main/ipc/browser-session-profile-ipc.test.ts": 342, + "src/main/ipc/browser-tab-registration-wait.test.ts": 259, + "src/main/ipc/browser.test.ts": 731, + "src/main/ipc/cli-appimage-stale-registration.test.ts": 272, + "src/main/ipc/cli.test.ts": 145, + "src/main/ipc/codex-config-sync.test.ts": 86, + "src/main/ipc/command-path-resolver.test.ts": 84, + "src/main/ipc/computer-use-permissions.test.ts": 62, + "src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts": 201, + "src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts": 95, + "src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts": 86, + "src/main/ipc/crash-reporting.test.ts": 194, + "src/main/ipc/created-worktree-reconciliation.test.ts": 142, + "src/main/ipc/created-worktree-root-prune.test.ts": 404, + "src/main/ipc/credential-change-rate-limit-refresh.test.ts": 129, + "src/main/ipc/dashboard-payload-validation.test.ts": 200, + "src/main/ipc/dashboard-popout.test.ts": 134, + "src/main/ipc/deferred-emoji-shortcode-dataset.test.ts": 105, + "src/main/ipc/developer-permissions.test.ts": 262, + "src/main/ipc/diagnostics.test.ts": 104, + "src/main/ipc/doc-preview-grant-ipc.test.ts": 77, + "src/main/ipc/dropped-path-resolution.test.ts": 74, + "src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts": 163, + "src/main/ipc/emulator-renderer-lifetime.test.ts": 117, + "src/main/ipc/emulator-stream-listener-cleanup.test.ts": 66, + "src/main/ipc/ephemeral-vm-provision-cancel.test.ts": 5904, + "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts": 3154, + "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts": 1695, + "src/main/ipc/ephemeral-vm.test.ts": 938, + "src/main/ipc/feedback-image-attachments.test.ts": 95, + "src/main/ipc/feedback-request-ownership.test.ts": 72, + "src/main/ipc/feedback.test.ts": 140, + "src/main/ipc/filesystem-allowed-roots.test.ts": 577, + "src/main/ipc/filesystem-auth.test.ts": 686, + "src/main/ipc/filesystem-branch-compare-diff.test.ts": 1287, + "src/main/ipc/filesystem-canonical-owner.test.ts": 135, + "src/main/ipc/filesystem-commit-message-generation.test.ts": 1529, + "src/main/ipc/filesystem-commit-message-model-discovery.test.ts": 2164, + "src/main/ipc/filesystem-conflict-operation-routing.test.ts": 1720, + "src/main/ipc/filesystem-content-search-cancellation.test.ts": 1714, + "src/main/ipc/filesystem-document-folder-writes.test.ts": 1153, + "src/main/ipc/filesystem-download-lifetime.test.ts": 722, + "src/main/ipc/filesystem-download-transfers.test.ts": 1825, + "src/main/ipc/filesystem-git-commit-dispatch.test.ts": 1766, + "src/main/ipc/filesystem-git-status-staging.test.ts": 1611, + "src/main/ipc/filesystem-import-local-rollback.test.ts": 87, + "src/main/ipc/filesystem-import-ssh-ops.test.ts": 1162, + "src/main/ipc/filesystem-import-ssh-path-safety.test.ts": 101, + "src/main/ipc/filesystem-import-ssh.test.ts": 1053, + "src/main/ipc/filesystem-import.test.ts": 2156, + "src/main/ipc/filesystem-list-files-handler-name-filter.test.ts": 1379, + "src/main/ipc/filesystem-list-files-name-filter.test.ts": 426, + "src/main/ipc/filesystem-list-files.test.ts": 778, + "src/main/ipc/filesystem-markdown-document-listing.test.ts": 1718, + "src/main/ipc/filesystem-mutations-runtime-upload.test.ts": 386, + "src/main/ipc/filesystem-mutations.test.ts": 1142, + "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts": 46, + "src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts": 492, + "src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts": 2316, + "src/main/ipc/filesystem-quick-open-options.integration.test.ts": 1700, + "src/main/ipc/filesystem-runtime-upload-staging.test.ts": 79, + "src/main/ipc/filesystem-search-file-paths.test.ts": 346, + "src/main/ipc/filesystem-search-rg-timeout.test.ts": 1670, + "src/main/ipc/filesystem-symlink-directory-entries.test.ts": 44, + "src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 509, + "src/main/ipc/filesystem-watcher-document-lifetime.test.ts": 381, + "src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 384, + "src/main/ipc/filesystem-watcher-event-batch.test.ts": 44, + "src/main/ipc/filesystem-watcher-ignore-real.test.ts": 424, + "src/main/ipc/filesystem-watcher-ignore.test.ts": 67, + "src/main/ipc/filesystem-watcher-large-batch.test.ts": 173, + "src/main/ipc/filesystem-watcher-local-events.test.ts": 406, + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 1181, + "src/main/ipc/filesystem-watcher-native-capacity.test.ts": 174, + "src/main/ipc/filesystem-watcher-real.test.ts": 546, + "src/main/ipc/filesystem-watcher-remote-batch.test.ts": 198, + "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 474, + "src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 195, + "src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 160, + "src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 396, + "src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 167, + "src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 189, + "src/main/ipc/filesystem-watcher-wsl.test.ts": 95, + "src/main/ipc/filesystem-watcher.test.ts": 2015, + "src/main/ipc/filesystem.test.ts": 2109, + "src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts": 50, + "src/main/ipc/filesystem/local-file-range-read.test.ts": 64, + "src/main/ipc/filesystem/local-regular-file-read.test.ts": 83, + "src/main/ipc/floating-workspace-directory.test.ts": 59, + "src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts": 100, + "src/main/ipc/folder-repo-git-upgrade.test.ts": 2130, + "src/main/ipc/freebuff-detection.test.ts": 42, + "src/main/ipc/git-status-upstream-ref-watch-request.test.ts": 321, + "src/main/ipc/github-issue-source-preference.test.ts": 602, + "src/main/ipc/github-pr-refresh-routing.test.ts": 729, + "src/main/ipc/github-repo-access-guards.test.ts": 690, + "src/main/ipc/github-ssh-connection-routing.test.ts": 658, + "src/main/ipc/github-star-telemetry.test.ts": 592, + "src/main/ipc/github-work-item-args.test.ts": 56, + "src/main/ipc/github-wsl-runtime-routing.test.ts": 680, + "src/main/ipc/gitlab-repo-access.test.ts": 105, + "src/main/ipc/gitlab.test.ts": 203, + "src/main/ipc/hooks/worktree-hook-execution-host.test.ts": 783, + "src/main/ipc/hosted-review.test.ts": 668, + "src/main/ipc/jira-cancellable-requests.test.ts": 58, + "src/main/ipc/keybindings.test.ts": 57, + "src/main/ipc/linear.test.ts": 110, + "src/main/ipc/local-file-access-resolution-network-path.test.ts": 176, + "src/main/ipc/local-file-access-resolution.test.ts": 435, + "src/main/ipc/local-log-tail-lifetime.test.ts": 131, + "src/main/ipc/local-log-tail.test.ts": 65, + "src/main/ipc/local-network-connection-test.test.ts": 323, + "src/main/ipc/local-worktree-runtime-options.test.ts": 105, + "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts": 66, + "src/main/ipc/macos-keyboard-layout-snapshot.test.ts": 47, + "src/main/ipc/managed-host-status-publication.test.ts": 1003, + "src/main/ipc/markdown-documents-remote-paths.test.ts": 180, + "src/main/ipc/markdown-documents-ripgrep-real.test.ts": 190, + "src/main/ipc/markdown-documents-ripgrep.test.ts": 250, + "src/main/ipc/markdown-documents.test.ts": 212, + "src/main/ipc/minimax-credentials.test.ts": 76, + "src/main/ipc/mobile.test.ts": 27111, + "src/main/ipc/native-chat-renderer-lifetime.test.ts": 910, + "src/main/ipc/native-chat-renderer-watcher-cleanup.test.ts": 1169, + "src/main/ipc/native-chat-subscribe-lifecycle.test.ts": 1282, + "src/main/ipc/native-chat.test.ts": 2590, + "src/main/ipc/notebook-project-link.test.ts": 365, + "src/main/ipc/notebook.test.ts": 507, + "src/main/ipc/notifications-custom-sound.test.ts": 328, + "src/main/ipc/notifications-delivery-gating.test.ts": 426, + "src/main/ipc/notifications-message-formatting.test.ts": 402, + "src/main/ipc/notifications-mobile-fanout.test.ts": 319, + "src/main/ipc/notifications-permission-onboarding.test.ts": 333, + "src/main/ipc/notifications-retention-lifecycle.test.ts": 506, + "src/main/ipc/notifications-subject-retirement.test.ts": 411, + "src/main/ipc/opencode-go-credentials.test.ts": 81, + "src/main/ipc/orca-profile-auth-handlers.test.ts": 473, + "src/main/ipc/orca-profile-org-members-handlers.test.ts": 61, + "src/main/ipc/orca-profiles-switch-persistence.test.ts": 2035, + "src/main/ipc/orca-profiles.test.ts": 706, + "src/main/ipc/orcad-delta-move-handlers.test.ts": 750, + "src/main/ipc/orcad-runtime-conversion-handlers.test.ts": 650, + "src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts": 47, + "src/main/ipc/orcad-runtime-maintenance-handlers.test.ts": 826, + "src/main/ipc/orcad-ssh-provisioning-handlers.test.ts": 51, + "src/main/ipc/parcel-watcher-child-launch.test.ts": 66, + "src/main/ipc/parcel-watcher-child-registry.test.ts": 98, + "src/main/ipc/parcel-watcher-crash-fuse.test.ts": 48, + "src/main/ipc/parcel-watcher-disconnect-termination.test.ts": 142, + "src/main/ipc/parcel-watcher-entry-path.test.ts": 61, + "src/main/ipc/parcel-watcher-event-cancellation.test.ts": 64, + "src/main/ipc/parcel-watcher-event-delivery.test.ts": 119, + "src/main/ipc/parcel-watcher-module-loader.test.ts": 51, + "src/main/ipc/parcel-watcher-native-error-retirement.test.ts": 106, + "src/main/ipc/parcel-watcher-owned-children.test.ts": 52, + "src/main/ipc/parcel-watcher-process-dispose.test.ts": 55, + "src/main/ipc/parcel-watcher-process-entry.test.ts": 215, + "src/main/ipc/parcel-watcher-process.test.ts": 562, + "src/main/ipc/parcel-watcher-root-path-rewrite.test.ts": 114, + "src/main/ipc/parcel-watcher-shallow-subscription.test.ts": 146, + "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts": 173, + "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts": 232, + "src/main/ipc/parcel-watcher-supervisor-disposal.test.ts": 90, + "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts": 112, + "src/main/ipc/pet-bundle.test.ts": 57, + "src/main/ipc/pet.test.ts": 319, + "src/main/ipc/plugin-marketplaces.test.ts": 275, + "src/main/ipc/plugins.test.ts": 437, + "src/main/ipc/preflight-agent-detection-no-subprocess.test.ts": 688, + "src/main/ipc/preflight-agent-detection.test.ts": 725, + "src/main/ipc/preflight-agent-refresh.test.ts": 717, + "src/main/ipc/preflight-command-exec.test.ts": 89, + "src/main/ipc/preflight-host-cli-status.test.ts": 731, + "src/main/ipc/preflight-provider-command-selection.test.ts": 352, + "src/main/ipc/preflight-remote-ssh.test.ts": 631, + "src/main/ipc/preflight-runnable-local-cli.test.ts": 9174, + "src/main/ipc/preflight-windows-path-refresh.repro.test.ts": 77, + "src/main/ipc/preflight-wsl-agent-detection.test.ts": 118, + "src/main/ipc/preflight-wsl-command.test.ts": 64, + "src/main/ipc/pty-activation-inventory-scope.test.ts": 1905, + "src/main/ipc/pty-agent-launch-pane-attach.test.ts": 2906, + "src/main/ipc/pty-applied-size-reporting.test.ts": 2036, + "src/main/ipc/pty-buffer-snapshot-dispatch.test.ts": 2028, + "src/main/ipc/pty-codex-account-attribution.test.ts": 1676, + "src/main/ipc/pty-controller-owner-recovery.test.ts": 1924, + "src/main/ipc/pty-controller-ownership-routing.test.ts": 2013, + "src/main/ipc/pty-controller-process-inventory.test.ts": 2060, + "src/main/ipc/pty-controller-spawn-admission.test.ts": 4498, + "src/main/ipc/pty-cumulative-ack-accounting.test.ts": 2167, + "src/main/ipc/pty-daemon-controller-teardown.test.ts": 2056, + "src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts": 2293, + "src/main/ipc/pty-daemon-spawn-codex-auth.test.ts": 2131, + "src/main/ipc/pty-daemon-spawn-session-identity.test.ts": 2097, + "src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts": 2152, + "src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts": 1871, + "src/main/ipc/pty-dead-owner-respawn.test.ts": 2153, + "src/main/ipc/pty-delivery-health-heal.test.ts": 2042, + "src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts": 2140, + "src/main/ipc/pty-encoding.test.ts": 43, + "src/main/ipc/pty-global-renderer-credit.test.ts": 2232, + "src/main/ipc/pty-hidden-at-spawn-mark.test.ts": 5138, + "src/main/ipc/pty-hidden-delivery-gate.test.ts": 59, + "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts": 2060, + "src/main/ipc/pty-ipc-producer-flow-control.test.ts": 1883, + "src/main/ipc/pty-listener-teardown-and-orphans.test.ts": 4358, + "src/main/ipc/pty-login-shell-startup-commands.test.ts": 2204, + "src/main/ipc/pty-management.test.ts": 138, + "src/main/ipc/pty-output-batching-drain.test.ts": 1870, + "src/main/ipc/pty-output-drain-rounds.test.ts": 1637, + "src/main/ipc/pty-pane-claim-arbitration.test.ts": 7990, + "src/main/ipc/pty-pane-materialization-race.test.ts": 2686, + "src/main/ipc/pty-pane-reservation-settlement.test.ts": 2440, + "src/main/ipc/pty-pane-restart-replace.test.ts": 2428, + "src/main/ipc/pty-pending-data-drain-queue-differential.test.ts": 1638, + "src/main/ipc/pty-pending-data-drain-queue.test.ts": 65, + "src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts": 93, + "src/main/ipc/pty-pending-output-cap.test.ts": 1952, + "src/main/ipc/pty-pending-projection-admissions.test.ts": 91, + "src/main/ipc/pty-persisted-incarnation-repair.test.ts": 1941, + "src/main/ipc/pty-producer-flow-control.test.ts": 60, + "src/main/ipc/pty-renderer-inflight-credit.test.ts": 2177, + "src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts": 2053, + "src/main/ipc/pty-renderer-liveness-guard.test.ts": 2073, + "src/main/ipc/pty-renderer-send-failure-recovery.test.ts": 2046, + "src/main/ipc/pty-reset-input-modes.test.ts": 1487, + "src/main/ipc/pty-restore-record-seeding.test.ts": 4523, + "src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts": 2019, + "src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts": 4718, + "src/main/ipc/pty-runtime-kill-and-exit.test.ts": 2264, + "src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts": 1912, + "src/main/ipc/pty-serializer-settlement-mapping.test.ts": 5457, + "src/main/ipc/pty-session-liveness-and-ownership.test.ts": 2154, + "src/main/ipc/pty-spawn-codex-home-unavailable.test.ts": 1758, + "src/main/ipc/pty-spawn-cwd-fallback.test.ts": 2244, + "src/main/ipc/pty-spawn-env-agent-overlays.test.ts": 2493, + "src/main/ipc/pty-spawn-env-codex-home-routing.test.ts": 2025, + "src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts": 2070, + "src/main/ipc/pty-spawn-env-terminal-basics.test.ts": 2141, + "src/main/ipc/pty-spawn-pi-owner-env.test.ts": 2128, + "src/main/ipc/pty-spawn-placement-threading.test.ts": 2049, + "src/main/ipc/pty-spawn-runtime-handle-binding.test.ts": 5122, + "src/main/ipc/pty-spawn-timing.test.ts": 57, + "src/main/ipc/pty-ssh-stop-verdict.test.ts": 2449, + "src/main/ipc/pty-ssh-undelivered-kill.test.ts": 2026, + "src/main/ipc/pty-startup-barrier-and-listing.test.ts": 2108, + "src/main/ipc/pty-startup-swap-window-presence.test.ts": 1988, + "src/main/ipc/pty-windows-shell-selection.test.ts": 2490, + "src/main/ipc/pty-write-ipc-validation.test.ts": 2150, + "src/main/ipc/pty-wsl-cwd-validation.test.ts": 2113, + "src/main/ipc/pty/configured-terminal-shell-args.test.ts": 54, + "src/main/ipc/pty/daemon-late-exit.test.ts": 4940, + "src/main/ipc/pty/delivery/attached-pty-size.test.ts": 53, + "src/main/ipc/pty/delivery/interactive-typing-burst.test.ts": 51, + "src/main/ipc/pty/host-env/codex-hook-reconcile-spawn.test.ts": 291, + "src/main/ipc/pty/host-env/omp-launch-environment.test.ts": 75, + "src/main/ipc/pty/host-env/opencode-hook-installation.test.ts": 669, + "src/main/ipc/pty/ipc/claude-old-terminal.test.ts": 48, + "src/main/ipc/pty/ipc/codex-shared-server.test.ts": 76, + "src/main/ipc/pty/ipc/leaf-move.test.ts": 628, + "src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts": 819, + "src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts": 1887, + "src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts": 880, + "src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts": 1183, + "src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts": 1122, + "src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts": 77, + "src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts": 1409, + "src/main/ipc/pty/ipc/terminal-view-attributes-ipc.test.ts": 400, + "src/main/ipc/pty/ipc/write-input-acceptance.test.ts": 77, + "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts": 69, + "src/main/ipc/pty/ipc/write-input-user-input.test.ts": 69, + "src/main/ipc/pty/managed-data-account-spawn-environment.test.ts": 770, + "src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts": 790, + "src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts": 721, + "src/main/ipc/pty/pane/join-pane-spawn.test.ts": 924, + "src/main/ipc/pty/pane/launch-authority.test.ts": 71, + "src/main/ipc/pty/pane/pane-owner-replacement.test.ts": 1478, + "src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts": 5118, + "src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts": 814, + "src/main/ipc/pty/provider/registry-color-query-reply-colors.test.ts": 398, + "src/main/ipc/pty/provider/registry-host-routing.test.ts": 417, "src/main/ipc/pty/register-headless-runtime.test.ts": 43, - "src/main/ipc/pty/register-without-renderer.test.ts": 3683, - "src/main/ipc/pty/runtime/queried-host-kinds.test.ts": 212, - "src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts": 1027, - "src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts": 970, - "src/main/ipc/rate-limits.test.ts": 73, - "src/main/ipc/readdir-error-diagnostics.test.ts": 27, - "src/main/ipc/register-core-handlers/register-core-handlers.test.ts": 1578, - "src/main/ipc/registered-worktree-root-ownership.test.ts": 366, - "src/main/ipc/registered-worktree-root-refresh.test.ts": 122, - "src/main/ipc/remote-watcher-event-batch.test.ts": 49, - "src/main/ipc/remote-workspace-cache.test.ts": 2439, - "src/main/ipc/remote-workspace-connected-clients.test.ts": 70, - "src/main/ipc/remote-workspace-patch-queue.test.ts": 474, - "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 43, - "src/main/ipc/remote-workspace-stale-resync.test.ts": 1371, - "src/main/ipc/remote-workspace.test.ts": 292, - "src/main/ipc/renderer-lifetime-abort.test.ts": 53, - "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 62, - "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 59, - "src/main/ipc/repos-add-linked-worktree.test.ts": 1070, - "src/main/ipc/repos-create.test.ts": 966, - "src/main/ipc/repos-execution-host-catalog.test.ts": 943, - "src/main/ipc/repos-local-add-and-project-setup.test.ts": 1193, - "src/main/ipc/repos-local-clone-lifecycle.test.ts": 1467, - "src/main/ipc/repos-nested-import.test.ts": 1374, - "src/main/ipc/repos-nested-scan.test.ts": 586, - "src/main/ipc/repos-picker.test.ts": 1130, - "src/main/ipc/repos-remote-base-ref-queries.test.ts": 1008, - "src/main/ipc/repos-remote-client-events.test.ts": 1549, - "src/main/ipc/repos-remote-git-username.test.ts": 940, - "src/main/ipc/repos-remote.test.ts": 958, - "src/main/ipc/repos-sparse-presets.test.ts": 1305, - "src/main/ipc/repos/remote-repo-registration.test.ts": 230, - "src/main/ipc/repos/repo-creation-git-availability.test.ts": 150, - "src/main/ipc/runtime-agent-launch-capability.test.ts": 2099, - "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 179, - "src/main/ipc/runtime-environment-capability-evidence.test.ts": 31, - "src/main/ipc/runtime-environment-cli-removal-routing.test.ts": 212, - "src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts": 42, - "src/main/ipc/runtime-environment-federated-read-routing.test.ts": 233, - "src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts": 163, - "src/main/ipc/runtime-environment-removal-storage.test.ts": 810, - "src/main/ipc/runtime-environment-request-connections.test.ts": 1502, - "src/main/ipc/runtime-environment-revision-guard.test.ts": 48, - "src/main/ipc/runtime-environment-status-connection.test.ts": 637, - "src/main/ipc/runtime-environment-status-recovery.test.ts": 246, - "src/main/ipc/runtime-environment-support-routing.test.ts": 361, - "src/main/ipc/runtime-environment-transport-generation.test.ts": 45, - "src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts": 1055, - "src/main/ipc/runtime-environments-call-routing.test.ts": 626, - "src/main/ipc/runtime-environments-capability-cache.test.ts": 482, - "src/main/ipc/runtime-environments-pairing.test.ts": 1362, - "src/main/ipc/runtime-environments-status-diagnostics.test.ts": 637, - "src/main/ipc/runtime-environments-subscription-lifecycle.test.ts": 989, - "src/main/ipc/runtime-environments-subscription-routing.test.ts": 524, - "src/main/ipc/runtime-environments-subscription-teardown.test.ts": 324, + "src/main/ipc/pty/register-without-renderer.test.ts": 5362, + "src/main/ipc/pty/runtime/queried-host-kinds.test.ts": 512, + "src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts": 1018, + "src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts": 833, + "src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts": 2118, + "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts": 1046, + "src/main/ipc/qoder-desktop-execution-host.test.ts": 1898, + "src/main/ipc/qoder-independent-renderer-spawn-review.test.ts": 1589, + "src/main/ipc/rate-limits.test.ts": 52, + "src/main/ipc/readdir-error-diagnostics.test.ts": 41, + "src/main/ipc/register-core-handlers/register-core-handlers.test.ts": 2495, + "src/main/ipc/registered-worktree-root-ownership.test.ts": 438, + "src/main/ipc/registered-worktree-root-refresh.test.ts": 184, + "src/main/ipc/registered-worktree-root-wsl-runtime.test.ts": 171, + "src/main/ipc/registered-worktree-roots-scoped-invalidation.test.ts": 89, + "src/main/ipc/remote-watcher-event-batch.test.ts": 77, + "src/main/ipc/remote-workspace-cache.test.ts": 5428, + "src/main/ipc/remote-workspace-connected-clients.test.ts": 51, + "src/main/ipc/remote-workspace-patch-queue.test.ts": 595, + "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 47, + "src/main/ipc/remote-workspace-stale-resync.test.ts": 1369, + "src/main/ipc/remote-workspace-target-session-export.test.ts": 254, + "src/main/ipc/remote-workspace.test.ts": 307, + "src/main/ipc/renderer-lifetime-abort.test.ts": 58, + "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 457, + "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 56, + "src/main/ipc/repos-add-linked-worktree.test.ts": 1324, + "src/main/ipc/repos-create.test.ts": 1219, + "src/main/ipc/repos-execution-host-catalog.test.ts": 1173, + "src/main/ipc/repos-local-add-and-project-setup.test.ts": 1476, + "src/main/ipc/repos-local-clone-lifecycle.test.ts": 1644, + "src/main/ipc/repos-nested-import.test.ts": 1393, + "src/main/ipc/repos-nested-scan.test.ts": 1295, + "src/main/ipc/repos-picker.test.ts": 1109, + "src/main/ipc/repos-remote-base-ref-queries.test.ts": 1223, + "src/main/ipc/repos-remote-client-events.test.ts": 1788, + "src/main/ipc/repos-remote-git-username.test.ts": 1104, + "src/main/ipc/repos-remote.test.ts": 1533, + "src/main/ipc/repos-sparse-presets.test.ts": 1196, + "src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts": 1525, + "src/main/ipc/repos/remote-repo-registration.test.ts": 282, + "src/main/ipc/repos/repo-creation-git-availability.test.ts": 310, + "src/main/ipc/runtime-agent-launch-capability.test.ts": 3143, + "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 1143, + "src/main/ipc/runtime-environment-capability-evidence.test.ts": 46, + "src/main/ipc/runtime-environment-cli-removal-routing.test.ts": 1153, + "src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts": 43, + "src/main/ipc/runtime-environment-federated-read-routing.test.ts": 1096, + "src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts": 328, + "src/main/ipc/runtime-environment-host-key.test.ts": 181, + "src/main/ipc/runtime-environment-managed-tunnel.test.ts": 483, + "src/main/ipc/runtime-environment-preference.test.ts": 401, + "src/main/ipc/runtime-environment-removal-cleanup.test.ts": 53, + "src/main/ipc/runtime-environment-removal-storage.test.ts": 1576, + "src/main/ipc/runtime-environment-request-connections.test.ts": 2790, + "src/main/ipc/runtime-environment-revision-guard.test.ts": 43, + "src/main/ipc/runtime-environment-session-reconcile.test.ts": 977, + "src/main/ipc/runtime-environment-status-connection.test.ts": 1592, + "src/main/ipc/runtime-environment-status-recovery.test.ts": 1038, + "src/main/ipc/runtime-environment-status-retirement.test.ts": 1009, + "src/main/ipc/runtime-environment-subscription-identity.test.ts": 1066, + "src/main/ipc/runtime-environment-support-routing.test.ts": 1306, + "src/main/ipc/runtime-environment-transport-generation.test.ts": 42, + "src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts": 2342, + "src/main/ipc/runtime-environments-call-routing.test.ts": 2632, + "src/main/ipc/runtime-environments-capability-cache.test.ts": 1740, + "src/main/ipc/runtime-environments-pairing.test.ts": 2250, + "src/main/ipc/runtime-environments-status-diagnostics.test.ts": 1491, + "src/main/ipc/runtime-environments-subscription-lifecycle.test.ts": 2695, + "src/main/ipc/runtime-environments-subscription-routing.test.ts": 1823, + "src/main/ipc/runtime-environments-subscription-teardown.test.ts": 1629, "src/main/ipc/runtime-import-limits.test.ts": 46, - "src/main/ipc/runtime-subscribe-lifecycle.test.ts": 2028, - "src/main/ipc/runtime-upload-file-stream.test.ts": 167, - "src/main/ipc/runtime-upload-slice-boundaries.test.ts": 352, - "src/main/ipc/runtime-upload-temp-sweep.test.ts": 1063, - "src/main/ipc/runtime-watcher-pending-assignment.test.ts": 307, - "src/main/ipc/runtime-watcher-process-pool.test.ts": 143, - "src/main/ipc/runtime.test.ts": 2155, - "src/main/ipc/sender-scoped-request-cancellation.test.ts": 99, - "src/main/ipc/settings.test.ts": 4454, - "src/main/ipc/shallow-watch-delivery-probe-lifetime.test.ts": 58, - "src/main/ipc/shallow-watch-delivery-probe.test.ts": 161, - "src/main/ipc/shell.test.ts": 91, - "src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 91, - "src/main/ipc/skill-cloud-ipc-handlers.test.ts": 220, - "src/main/ipc/skill-install-management-ipc-handlers.test.ts": 178, - "src/main/ipc/skill-install-progress-ipc.test.ts": 35, - "src/main/ipc/skill-ipc-main-window.test.ts": 61, - "src/main/ipc/skills.test.ts": 543, - "src/main/ipc/source-control-ai-linked-issue.test.ts": 51, - "src/main/ipc/speech-worker-exit-lifetime.test.ts": 337, - "src/main/ipc/speech.test.ts": 75, - "src/main/ipc/ssh-active-relay-sessions.test.ts": 41, - "src/main/ipc/ssh-app-shutdown.test.ts": 1719, - "src/main/ipc/ssh-browse.test.ts": 737, - "src/main/ipc/ssh-disconnect-cancellation.test.ts": 3816, - "src/main/ipc/ssh-handler-reregistration.test.ts": 2634, - "src/main/ipc/ssh-host-partition-session-export.test.ts": 1162, - "src/main/ipc/ssh-passphrase.test.ts": 137, - "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 67, - "src/main/ipc/ssh-pty-consumer-identity.test.ts": 3105, - "src/main/ipc/ssh-pty-legacy-projection.test.ts": 62, - "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 58, - "src/main/ipc/ssh-pty-model-admission.test.ts": 76, - "src/main/ipc/ssh-pty-output-exit-deadline.test.ts": 83, - "src/main/ipc/ssh-pty-output-generation-guard.test.ts": 48, - "src/main/ipc/ssh-pty-output-intake.test.ts": 165, - "src/main/ipc/ssh-pty-output-model-migration.test.ts": 109, - "src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts": 85, - "src/main/ipc/ssh-pty-source-ack-coalescer.test.ts": 48, - "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 62, - "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 45, - "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 170, - "src/main/ipc/ssh-relay-reset-resume.test.ts": 3804, - "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 2574, - "src/main/ipc/ssh-target-registry.test.ts": 3704, - "src/main/ipc/ssh-terminate-sessions.test.ts": 3008, - "src/main/ipc/ssh.test.ts": 2028, - "src/main/ipc/telemetry.test.ts": 230, - "src/main/ipc/terminal-git-credential-guard.test.ts": 82, - "src/main/ipc/terminal-preview-output-stream.test.ts": 28, - "src/main/ipc/terminal-preview.test.ts": 242, - "src/main/ipc/terminal-render-desync-evidence.test.ts": 63, - "src/main/ipc/terminal-startup-color-query-replies.test.ts": 138, - "src/main/ipc/tui-agent-detection-commands.test.ts": 44, - "src/main/ipc/ui.test.ts": 84, - "src/main/ipc/usage-provider-handlers.test.ts": 44, - "src/main/ipc/watched-worktree-catalog-notification.test.ts": 71, - "src/main/ipc/watcher-event-root-path-rewrite.test.ts": 61, - "src/main/ipc/watcher-removal-gate.test.ts": 37, - "src/main/ipc/workspace-cleanup-activity.test.ts": 80, - "src/main/ipc/workspace-cleanup-broad-scan.test.ts": 531, - "src/main/ipc/workspace-cleanup-execution-host-routing.test.ts": 305, - "src/main/ipc/workspace-cleanup-local-git-routing.test.ts": 461, - "src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts": 114, - "src/main/ipc/workspace-cleanup.test.ts": 4644, + "src/main/ipc/runtime-ssh-access-handlers.test.ts": 191, + "src/main/ipc/runtime-subscribe-lifecycle.test.ts": 2991, + "src/main/ipc/runtime-upload-file-stream.test.ts": 140, + "src/main/ipc/runtime-upload-slice-boundaries.test.ts": 373, + "src/main/ipc/runtime-upload-temp-sweep.test.ts": 1055, + "src/main/ipc/runtime-watcher-disposal-owners.test.ts": 51, + "src/main/ipc/runtime-watcher-pending-assignment.test.ts": 294, + "src/main/ipc/runtime-watcher-pool-shutdown.test.ts": 91, + "src/main/ipc/runtime-watcher-process-pool.test.ts": 146, + "src/main/ipc/runtime.test.ts": 3357, + "src/main/ipc/sender-scoped-request-cancellation.test.ts": 96, + "src/main/ipc/session-close-terminal-surface.test.ts": 346, + "src/main/ipc/session.test.ts": 431, + "src/main/ipc/settings.test.ts": 1109, + "src/main/ipc/shallow-watch-delivery-probe-lifetime.test.ts": 92, + "src/main/ipc/shallow-watch-delivery-probe.test.ts": 104, + "src/main/ipc/shell.test.ts": 168, + "src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 269, + "src/main/ipc/skill-cloud-ipc-handlers.test.ts": 280, + "src/main/ipc/skill-install-management-ipc-handlers.test.ts": 274, + "src/main/ipc/skill-install-progress-ipc.test.ts": 50, + "src/main/ipc/skill-ipc-main-window.test.ts": 56, + "src/main/ipc/skills.test.ts": 1180, + "src/main/ipc/source-control-ai-linked-issue.test.ts": 89, + "src/main/ipc/sparse-preset-attribution.test.ts": 43, + "src/main/ipc/speech-worker-exit-lifetime.test.ts": 529, + "src/main/ipc/speech.test.ts": 80, + "src/main/ipc/ssh-app-shutdown.test.ts": 4272, + "src/main/ipc/ssh-browse.test.ts": 706, + "src/main/ipc/ssh-connect-recheck-cancellation.test.ts": 4164, + "src/main/ipc/ssh-disconnect-cancellation.test.ts": 5166, + "src/main/ipc/ssh-handler-reregistration.test.ts": 4043, + "src/main/ipc/ssh-host-partition-session-export.test.ts": 1644, + "src/main/ipc/ssh-host-server-decision-failure.test.ts": 385, + "src/main/ipc/ssh-host-server-on-connect-wiring.test.ts": 2374, + "src/main/ipc/ssh-host-sleep-reconnect.test.ts": 424, + "src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts": 487, + "src/main/ipc/ssh-passphrase.test.ts": 104, + "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 71, + "src/main/ipc/ssh-pty-legacy-projection.test.ts": 67, + "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 69, + "src/main/ipc/ssh-pty-model-admission.test.ts": 63, + "src/main/ipc/ssh-pty-output-exit-deadline.test.ts": 95, + "src/main/ipc/ssh-pty-output-generation-guard.test.ts": 56, + "src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts": 104, + "src/main/ipc/ssh-pty-output-intake.test.ts": 137, + "src/main/ipc/ssh-pty-output-model-migration.test.ts": 117, + "src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts": 80, + "src/main/ipc/ssh-pty-source-ack-coalescer.test.ts": 62, + "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 88, + "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 63, + "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 189, + "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 4164, + "src/main/ipc/ssh-target-crud-handlers-ownership.test.ts": 560, + "src/main/ipc/ssh-target-registry.test.ts": 6430, + "src/main/ipc/ssh-test-connection-probe-registry.test.ts": 46, + "src/main/ipc/ssh-test-probe-presence.test.ts": 63, + "src/main/ipc/ssh.test.ts": 4177, + "src/main/ipc/telemetry.test.ts": 297, + "src/main/ipc/terminal-git-credential-guard.test.ts": 103, + "src/main/ipc/terminal-preview-output-stream.test.ts": 57, + "src/main/ipc/terminal-preview.test.ts": 237, + "src/main/ipc/terminal-render-desync-evidence.test.ts": 88, + "src/main/ipc/terminal-startup-color-query-replies.test.ts": 322, + "src/main/ipc/tui-agent-detection-commands.test.ts": 60, + "src/main/ipc/ui.test.ts": 86, + "src/main/ipc/usage-provider-handlers-readiness.test.ts": 597, + "src/main/ipc/usage-provider-handlers.test.ts": 59, + "src/main/ipc/watched-worktree-catalog-notification.test.ts": 74, + "src/main/ipc/watcher-event-root-path-rewrite.test.ts": 56, + "src/main/ipc/watcher-removal-gate.test.ts": 52, + "src/main/ipc/workspace-cleanup-activity.test.ts": 89, + "src/main/ipc/workspace-cleanup-broad-scan.test.ts": 686, + "src/main/ipc/workspace-cleanup-execution-host-routing.test.ts": 595, + "src/main/ipc/workspace-cleanup-local-git-routing.test.ts": 1167, + "src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts": 105, + "src/main/ipc/workspace-cleanup.test.ts": 2928, "src/main/ipc/workspace-create-error-classifier.test.ts": 52, - "src/main/ipc/workspace-ports.test.ts": 103, - "src/main/ipc/workspace-space.test.ts": 63, - "src/main/ipc/worktree-base-directory-change-collector.test.ts": 42, - "src/main/ipc/worktree-base-directory-event-filter.test.ts": 61, - "src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts": 219, - "src/main/ipc/worktree-base-directory-poller.test.ts": 2702, - "src/main/ipc/worktree-base-directory-watch-targets.test.ts": 388, - "src/main/ipc/worktree-base-directory-watcher.test.ts": 454, - "src/main/ipc/worktree-create-lineage.test.ts": 570, - "src/main/ipc/worktree-folder-rename-target.test.ts": 65, - "src/main/ipc/worktree-git-common-polling.test.ts": 1240, - "src/main/ipc/worktree-git-common-watch.test.ts": 2604, - "src/main/ipc/worktree-git-status-ref-watch.test.ts": 40, - "src/main/ipc/worktree-head-identity-reader-concurrency.test.ts": 72, - "src/main/ipc/worktree-head-identity-reader-incremental.test.ts": 255, - "src/main/ipc/worktree-head-identity-reader.test.ts": 99, - "src/main/ipc/worktree-head-identity-refresh.test.ts": 84, - "src/main/ipc/worktree-head-identity-retirement.test.ts": 80, - "src/main/ipc/worktree-include-copy-budget.test.ts": 119, - "src/main/ipc/worktree-logic-created-agent.test.ts": 82, - "src/main/ipc/worktree-logic-wsl.test.ts": 102, - "src/main/ipc/worktree-logic.test.ts": 185, - "src/main/ipc/worktree-metadata-merge.test.ts": 60, - "src/main/ipc/worktree-path-deduplication.test.ts": 69, - "src/main/ipc/worktree-push-target-cleanup.test.ts": 182, - "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 862, - "src/main/ipc/worktree-push-target-reconciliation.test.ts": 305, - "src/main/ipc/worktree-push-target-refspec-migration.test.ts": 178, - "src/main/ipc/worktree-push-target-refspec-real-git.test.ts": 2471, - "src/main/ipc/worktree-push-target-remote-scan.test.ts": 135, - "src/main/ipc/worktree-push-target-setup.test.ts": 223, - "src/main/ipc/worktree-remote-push-target-materialization.test.ts": 717, - "src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts": 500, - "src/main/ipc/worktree-symlink-reconciliation.real.test.ts": 239, - "src/main/ipc/worktree-symlinks.test.ts": 136, - "src/main/ipc/worktree-watcher-removal-binding.test.ts": 150, - "src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts": 1279, - "src/main/ipc/worktrees-canonical-ssh-listing.test.ts": 577, - "src/main/ipc/worktrees-create-execution-host-routing.test.ts": 588, - "src/main/ipc/worktrees-create-metadata-persistence.test.ts": 1369, - "src/main/ipc/worktrees-delete-pty-teardown.test.ts": 536, - "src/main/ipc/worktrees-detected-scan-cache.test.ts": 1356, - "src/main/ipc/worktrees-discovery-metadata-backfill.test.ts": 452, - "src/main/ipc/worktrees-existing-branch-checkout.test.ts": 1249, - "src/main/ipc/worktrees-forget-local.test.ts": 638, - "src/main/ipc/worktrees-issue-command-overrides.test.ts": 430, - "src/main/ipc/worktrees-lineage-hydration.test.ts": 886, - "src/main/ipc/worktrees-listing-fallback-rows.test.ts": 778, - "src/main/ipc/worktrees-local-base-ref-resolution.test.ts": 872, - "src/main/ipc/worktrees-local-create-flow.test.ts": 922, - "src/main/ipc/worktrees-mutation-scan-generation-order.test.ts": 971, - "src/main/ipc/worktrees-orphan-directory-cleanup.test.ts": 933, - "src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts": 816, - "src/main/ipc/worktrees-removal-recovery.test.ts": 1234, - "src/main/ipc/worktrees-remove-archive-hooks.test.ts": 1347, - "src/main/ipc/worktrees-remove-host-disambiguation.test.ts": 868, - "src/main/ipc/worktrees-remove-preflight.test.ts": 840, - "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 664, - "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 917, - "src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts": 925, - "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 717, - "src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts": 914, - "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 906, - "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 768, - "src/main/ipc/worktrees-ssh-provider-authority.test.ts": 895, - "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts": 1245, - "src/main/ipc/worktrees-ssh-setup-launch.test.ts": 927, - "src/main/ipc/worktrees-windows.test.ts": 1052, - "src/main/ipc/worktrees-wsl-runtime-routing.test.ts": 812, - "src/main/ipc/worktrees/listing/detected-provider-listing-catalog-version.test.ts": 107, - "src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts": 358, - "src/main/ipc/worktrees/listing/detected-provider-listing-overtaken-scan.test.ts": 127, - "src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts": 431, - "src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts": 148, - "src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts": 55, - "src/main/ipc/worktrees/listing/detected-worktree-scan-superseded.test.ts": 89, - "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 168, - "src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts": 54, - "src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts": 99, - "src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts": 211, - "src/main/issue-command-ignore.test.ts": 395, - "src/main/jira/adf-markdown.test.ts": 58, - "src/main/jira/attachment-image-cache-generation.test.ts": 46, - "src/main/jira/attachment-image-cache.test.ts": 47, - "src/main/jira/attachment-images.test.ts": 82, - "src/main/jira/client.test.ts": 304, - "src/main/jira/issues.test.ts": 172, - "src/main/jira/jira-issue-mutations.test.ts": 56, - "src/main/jira/jira-issue-summary-timeout.test.ts": 78, - "src/main/jira/jira-search-abort.test.ts": 42, - "src/main/keybindings/keybinding-file.test.ts": 119, - "src/main/keybindings/keybinding-service.test.ts": 140, - "src/main/kimi/hook-service-config-delete-race.test.ts": 124, + "src/main/ipc/workspace-ports.test.ts": 92, + "src/main/ipc/workspace-space.test.ts": 66, + "src/main/ipc/worktree-base-directory-change-collector.test.ts": 54, + "src/main/ipc/worktree-base-directory-event-filter.test.ts": 72, + "src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts": 204, + "src/main/ipc/worktree-base-directory-poller.test.ts": 2668, + "src/main/ipc/worktree-base-directory-watch-targets.test.ts": 437, + "src/main/ipc/worktree-base-directory-watcher.test.ts": 293, + "src/main/ipc/worktree-create-lineage.test.ts": 958, + "src/main/ipc/worktree-folder-rename-target.test.ts": 96, + "src/main/ipc/worktree-git-common-polling.test.ts": 1046, + "src/main/ipc/worktree-git-common-watch-reconciliation.test.ts": 112, + "src/main/ipc/worktree-git-common-watch.test.ts": 2683, + "src/main/ipc/worktree-git-status-ref-watch.test.ts": 76, + "src/main/ipc/worktree-head-identity-reader-concurrency.test.ts": 77, + "src/main/ipc/worktree-head-identity-reader-incremental.test.ts": 113, + "src/main/ipc/worktree-head-identity-reader.test.ts": 85, + "src/main/ipc/worktree-head-identity-refresh.test.ts": 87, + "src/main/ipc/worktree-head-identity-retirement.test.ts": 79, + "src/main/ipc/worktree-include-copy-budget.test.ts": 129, + "src/main/ipc/worktree-logic-created-agent.test.ts": 97, + "src/main/ipc/worktree-logic-wsl.test.ts": 104, + "src/main/ipc/worktree-logic.test.ts": 143, + "src/main/ipc/worktree-metadata-merge.test.ts": 52, + "src/main/ipc/worktree-path-deduplication.test.ts": 77, + "src/main/ipc/worktree-push-target-cleanup.test.ts": 454, + "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 789, + "src/main/ipc/worktree-push-target-reconciliation.test.ts": 466, + "src/main/ipc/worktree-push-target-refspec-migration.test.ts": 188, + "src/main/ipc/worktree-push-target-refspec-real-git.test.ts": 1650, + "src/main/ipc/worktree-push-target-remote-scan.test.ts": 440, + "src/main/ipc/worktree-push-target-setup.test.ts": 464, + "src/main/ipc/worktree-remote-push-target-materialization.test.ts": 772, + "src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts": 860, + "src/main/ipc/worktree-symlink-reconciliation.real.test.ts": 465, + "src/main/ipc/worktree-symlinks.test.ts": 155, + "src/main/ipc/worktree-watcher-removal-binding.test.ts": 51, + "src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts": 1670, + "src/main/ipc/worktrees-background-removal.test.ts": 1866, + "src/main/ipc/worktrees-canonical-ssh-listing.test.ts": 1212, + "src/main/ipc/worktrees-create-event-timing.test.ts": 1165, + "src/main/ipc/worktrees-create-metadata-persistence.test.ts": 1531, + "src/main/ipc/worktrees-delete-pty-teardown.test.ts": 1507, + "src/main/ipc/worktrees-detected-scan-cache.test.ts": 2006, + "src/main/ipc/worktrees-discovery-metadata-backfill.test.ts": 1324, + "src/main/ipc/worktrees-existing-branch-checkout.test.ts": 1158, + "src/main/ipc/worktrees-failed-removal.test.ts": 1242, + "src/main/ipc/worktrees-forget-local.test.ts": 1316, + "src/main/ipc/worktrees-issue-command-overrides.test.ts": 1354, + "src/main/ipc/worktrees-lineage-hydration.test.ts": 1297, + "src/main/ipc/worktrees-listing-fallback-rows.test.ts": 1487, + "src/main/ipc/worktrees-local-base-ref-resolution.test.ts": 1454, + "src/main/ipc/worktrees-local-create-flow.test.ts": 1392, + "src/main/ipc/worktrees-mutation-scan-generation-order.test.ts": 1353, + "src/main/ipc/worktrees-orphan-directory-cleanup.test.ts": 1334, + "src/main/ipc/worktrees-removal-recovery.test.ts": 1321, + "src/main/ipc/worktrees-remove-archive-hooks.test.ts": 1873, + "src/main/ipc/worktrees-remove-preflight.test.ts": 1662, + "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 1390, + "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 1298, + "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 1305, + "src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts": 1478, + "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 1443, + "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 1459, + "src/main/ipc/worktrees-ssh-provider-authority.test.ts": 1177, + "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts": 1671, + "src/main/ipc/worktrees-windows.test.ts": 1489, + "src/main/ipc/worktrees-wsl-runtime-routing.test.ts": 1166, + "src/main/ipc/worktrees/listing/detected-provider-listing-catalog-version.test.ts": 337, + "src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts": 1057, + "src/main/ipc/worktrees/listing/detected-provider-listing-overtaken-scan.test.ts": 390, + "src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts": 1030, + "src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts": 161, + "src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts": 93, + "src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts": 559, + "src/main/ipc/worktrees/listing/detected-worktree-scan-superseded.test.ts": 68, + "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 197, + "src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts": 80, + "src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts": 402, + "src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts": 442, + "src/main/ipc/zcode-plan-credentials.test.ts": 58, + "src/main/issue-command-ignore.test.ts": 620, + "src/main/jcode/daemon-prewarm.test.ts": 54, + "src/main/jcode/hook-command-quoting.test.ts": 54, + "src/main/jcode/hook-config.test.ts": 80, + "src/main/jcode/hook-gate-script.test.ts": 118, + "src/main/jcode/hook-service.test.ts": 114, + "src/main/jira/adf-markdown.test.ts": 57, + "src/main/jira/attachment-image-cache-generation.test.ts": 63, + "src/main/jira/attachment-image-cache.test.ts": 76, + "src/main/jira/attachment-images.test.ts": 80, + "src/main/jira/client.test.ts": 285, + "src/main/jira/issues.test.ts": 170, + "src/main/jira/jira-issue-mutations.test.ts": 100, + "src/main/jira/jira-issue-summary-timeout.test.ts": 80, + "src/main/jira/jira-search-abort.test.ts": 94, + "src/main/jsonl-rpc/agent-connection.test.ts": 224, + "src/main/jsonl-rpc/peer.test.ts": 223, + "src/main/jsonl-rpc/timeline-final-tail.test.ts": 523, + "src/main/jsonl-rpc/timeline-lane.test.ts": 471, + "src/main/keybindings/keybinding-file.test.ts": 185, + "src/main/keybindings/keybinding-service.test.ts": 138, + "src/main/kimi/hook-service-config-delete-race.test.ts": 90, "src/main/kimi/hook-service-mode-read-failure.test.ts": 86, - "src/main/kimi/hook-service.test.ts": 161, - "src/main/kimi/kimi-hook-config-toml.test.ts": 94, - "src/main/kimi/kimi-runtime-home.test.ts": 62, - "src/main/lib/unread-response-body.test.ts": 92, - "src/main/line-editor-ready-output-scanner.test.ts": 55, - "src/main/linear/client.test.ts": 107, - "src/main/linear/issue-context-client.test.ts": 61, - "src/main/linear/issue-context-current.test.ts": 63, - "src/main/linear/issue-context-errors.test.ts": 52, - "src/main/linear/issue-context-includes.test.ts": 66, - "src/main/linear/issue-context-inline-media.test.ts": 76, - "src/main/linear/issue-context-relations.test.ts": 37, - "src/main/linear/issue-context.test.ts": 66, - "src/main/linear/issue-list-filter.test.ts": 44, - "src/main/linear/issue-relation-write.test.ts": 79, - "src/main/linear/issue-search-summary.test.ts": 50, - "src/main/linear/issues.test.ts": 114, - "src/main/linear/linear-workspace-registry.test.ts": 39, - "src/main/linear/mappers.test.ts": 53, - "src/main/linear/mcp-issue-list-pagination.test.ts": 93, - "src/main/linear/mcp-issue-list.test.ts": 105, - "src/main/linear/projects.test.ts": 369, - "src/main/linear/teams.test.ts": 84, - "src/main/linux-lid-sleep-assertion.test.ts": 93, - "src/main/linux-package-install-command.test.ts": 46, - "src/main/linux-package-install-diagnostic.test.ts": 79, - "src/main/linux-package-update-recovery.test.ts": 361, - "src/main/linux-update-package-type.test.ts": 98, - "src/main/local-builds/local-build-candidate.test.ts": 265, - "src/main/local-builds/local-build-compatibility-contract.test.ts": 162, - "src/main/local-builds/local-build-feed-server.test.ts": 191, - "src/main/local-downloaded-folder-promotion.test.ts": 185, - "src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts": 54, - "src/main/local-worktree-filesystem.test.ts": 348, - "src/main/local-worktree-metadata-prune-gate.test.ts": 60, - "src/main/local-worktree-path-presence.test.ts": 77, - "src/main/local-worktree-removal-recovery-live.test.ts": 198, - "src/main/local-worktree-removal-recovery.test.ts": 123, - "src/main/localhost-worktree-label-proxy.test.ts": 141, - "src/main/macos-full-disk-access-status.test.ts": 54, - "src/main/macos-keychain/generic-password.test.ts": 61, - "src/main/macos-press-and-hold-default.defaults-domain.test.ts": 51, + "src/main/kimi/hook-service.test.ts": 93, + "src/main/kimi/kimi-hook-config-toml.test.ts": 109, + "src/main/kimi/kimi-runtime-home.test.ts": 66, + "src/main/kiro/hook-service.test.ts": 152, + "src/main/lib/html-to-pdf.test.ts": 79, + "src/main/lib/unread-response-body.test.ts": 60, + "src/main/line-editor-ready-output-scanner.test.ts": 49, + "src/main/linear/client.test.ts": 174, + "src/main/linear/issue-context-client.test.ts": 86, + "src/main/linear/issue-context-current.test.ts": 58, + "src/main/linear/issue-context-errors.test.ts": 45, + "src/main/linear/issue-context-includes.test.ts": 92, + "src/main/linear/issue-context-inline-media.test.ts": 85, + "src/main/linear/issue-context-relations.test.ts": 58, + "src/main/linear/issue-context.test.ts": 77, + "src/main/linear/issue-list-filter.test.ts": 55, + "src/main/linear/issue-relation-write.test.ts": 74, + "src/main/linear/issue-search-summary.test.ts": 45, + "src/main/linear/issues.test.ts": 133, + "src/main/linear/linear-description-images.test.ts": 163, + "src/main/linear/linear-issue-detail-deadline.test.ts": 225, + "src/main/linear/linear-workspace-registry.test.ts": 52, + "src/main/linear/mappers.test.ts": 43, + "src/main/linear/mcp-issue-list-pagination.test.ts": 100, + "src/main/linear/mcp-issue-list.test.ts": 142, + "src/main/linear/projects.test.ts": 394, + "src/main/linear/teams.test.ts": 85, + "src/main/linux-lid-sleep-assertion.test.ts": 95, + "src/main/linux-package-install-command.test.ts": 104, + "src/main/linux-package-install-diagnostic.test.ts": 71, + "src/main/linux-package-update-recovery.test.ts": 226, + "src/main/linux-update-package-type.test.ts": 140, + "src/main/local-builds/local-build-candidate.test.ts": 178, + "src/main/local-builds/local-build-compatibility-contract.test.ts": 295, + "src/main/local-builds/local-build-feed-server.test.ts": 88, + "src/main/local-downloaded-folder-promotion.test.ts": 163, + "src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts": 81, + "src/main/local-worktree-filesystem.test.ts": 368, + "src/main/local-worktree-metadata-prune-gate.test.ts": 56, + "src/main/local-worktree-path-presence.test.ts": 70, + "src/main/local-worktree-removal-recovery-live.test.ts": 385, + "src/main/local-worktree-removal-recovery.test.ts": 131, + "src/main/localhost-worktree-label-proxy.test.ts": 126, + "src/main/macos-full-disk-access-status.test.ts": 49, + "src/main/macos-keychain/generic-password.test.ts": 57, + "src/main/macos-press-and-hold-default.defaults-domain.test.ts": 68, "src/main/macos-press-and-hold-default.test.ts": 95, - "src/main/macos-system-sleep-assertion.test.ts": 73, - "src/main/macos-tcc-prompt-notice.test.ts": 61, - "src/main/macos-tcc-prompt-watch.test.ts": 66, - "src/main/macos-tcc-reset.test.ts": 41, - "src/main/main-process-tree-kill-gate.test.ts": 378, - "src/main/memory/collector-windows-sweep.test.ts": 455, - "src/main/memory/collector.test.ts": 333, + "src/main/macos-system-sleep-assertion.test.ts": 71, + "src/main/macos-tcc-prompt-notice.test.ts": 96, + "src/main/macos-tcc-prompt-watch.test.ts": 94, + "src/main/macos-tcc-reset.test.ts": 67, + "src/main/macos-update-running-instances.integration.test.ts": 47, + "src/main/macos-update-running-instances.test.ts": 69, + "src/main/managed-data-accounts/launch-environment.test.ts": 342, + "src/main/managed-data-accounts/original-environment.test.ts": 316, + "src/main/managed-data-accounts/profile-removal-recovery.test.ts": 629, + "src/main/managed-data-accounts/service-enrollment-persistence.test.ts": 397, + "src/main/managed-data-accounts/service.test.ts": 1418, + "src/main/media/media-preview-protocol.test.ts": 82, + "src/main/media/media-range-response.test.ts": 78, + "src/main/memory/collector-windows-sweep.test.ts": 699, + "src/main/memory/collector.test.ts": 815, "src/main/memory/host-memory.test.ts": 78, - "src/main/memory/hydrate-local-pty-registry.test.ts": 774, - "src/main/memory/memory-snapshot-buckets.test.ts": 56, - "src/main/memory/process-memory-metric.test.ts": 49, - "src/main/memory/windows-process-sample-parsing.test.ts": 95, - "src/main/menu/gpu-acceleration-about-panel.test.ts": 30, - "src/main/menu/register-app-menu.test.ts": 65, - "src/main/mimo/hook-service.test.ts": 66, - "src/main/minimax/minimax-api-key-store.test.ts": 88, - "src/main/minimax/minimax-cookie-store.test.ts": 71, - "src/main/muse-usage/scanner.test.ts": 162, - "src/main/muse-usage/snapshot-rollups.test.ts": 49, - "src/main/muse-usage/store-snapshot-filtering.test.ts": 475, - "src/main/muse/hook-config-json.test.ts": 85, - "src/main/muse/hook-service.test.ts": 134, - "src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts": 59, - "src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts": 550, - "src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts": 52, - "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts": 355, - "src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts": 346, - "src/main/native-chat/agent-session-journal/journal-cursor.test.ts": 54, - "src/main/native-chat/agent-session-journal/journal-database.test.ts": 177, - "src/main/native-chat/agent-session-journal/journal-dispatch-observation.test.ts": 45, - "src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts": 246, - "src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts": 234, - "src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts": 269, - "src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts": 5325, - "src/main/native-chat/agent-session-journal/journal-item-identity.test.ts": 64, - "src/main/native-chat/agent-session-journal/journal-legacy-import-source-bound.test.ts": 236, - "src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts": 451, - "src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts": 238, - "src/main/native-chat/agent-session-journal/journal-reducer.test.ts": 155, - "src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts": 324, - "src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts": 232, - "src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts": 248, - "src/main/native-chat/agent-session-journal/journal-row-schema.test.ts": 127, - "src/main/native-chat/agent-session-journal/journal-row-writer.test.ts": 187, - "src/main/native-chat/agent-session-journal/journal-store-close.test.ts": 249, - "src/main/native-chat/agent-session-journal/journal-store-schema.test.ts": 222, - "src/main/native-chat/agent-session-journal/journal-store.test.ts": 401, - "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts": 1019, - "src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts": 243, - "src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts": 200, - "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts": 40, - "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts": 41, - "src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts": 173, - "src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts": 497, - "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts": 1663, - "src/main/native-chat/agent-session-wire/agent-session-history-page-grouping-parity.test.ts": 309, - "src/main/native-chat/agent-session-wire/agent-session-history-page-scaling.test.ts": 75, - "src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts": 2331, - "src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts": 427, - "src/main/native-chat/agent-session-wire/agent-session-retired-provider-exit-copy-ratchet.test.ts": 1719, - "src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts": 52, - "src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts": 97, - "src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts": 210, - "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts": 1113, - "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts": 83, - "src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts": 47, - "src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts": 386, - "src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts": 626, - "src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts": 393, - "src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts": 687, - "src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts": 674, - "src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts": 404, - "src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts": 556, - "src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts": 297, - "src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts": 1198, - "src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts": 409, - "src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts": 259, - "src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts": 255, - "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts": 514, - "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts": 766, - "src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts": 780, - "src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts": 448, - "src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts": 72, - "src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts": 300, - "src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts": 170, - "src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts": 369, - "src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts": 1870, - "src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts": 442, - "src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts": 736, - "src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts": 301, - "src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts": 423, - "src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts": 389, - "src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts": 809, - "src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts": 600, - "src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts": 718, - "src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts": 1713, - "src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts": 477, - "src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts": 255, - "src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts": 558, - "src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts": 972, - "src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts": 370, - "src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts": 51, - "src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts": 816, - "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts": 739, - "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 297, - "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts": 3078, - "src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts": 48, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts": 785, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts": 330, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts": 1273, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts": 829, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts": 711, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts": 3393, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts": 320, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts": 48, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts": 131, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts": 354, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts": 613, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts": 900, - "src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts": 351, - "src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts": 1675, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts": 225, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts": 2117, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts": 871, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts": 42, - "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts": 1405, - "src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts": 812, - "src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts": 358, - "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts": 271, - "src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts": 168, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts": 288, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts": 311, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts": 699, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts": 99, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts": 791, - "src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts": 1004, - "src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts": 551, - "src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts": 2023, - "src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts": 89, - "src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts": 284, - "src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts": 67, - "src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts": 295, - "src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts": 405, - "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 127, - "src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts": 2947, - "src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts": 643, - "src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts": 57, - "src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts": 1455, - "src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts": 330, - "src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts": 123, - "src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts": 36, - "src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts": 64, - "src/main/native-chat/claude-structured-managed-account-support.test.ts": 37, - "src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 257, - "src/main/native-chat/host-readable-transcript-path.test.ts": 89, - "src/main/native-chat/native-chat-file-provenance.test.ts": 134, - "src/main/native-chat/session-file-resolver-claude-roots.test.ts": 131, - "src/main/native-chat/session-file-resolver-codex-roots.test.ts": 66, - "src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 95, - "src/main/native-chat/session-file-resolver-wsl.test.ts": 92, - "src/main/native-chat/session-file-resolver.test.ts": 108, - "src/main/native-chat/structured-agent-session-create-support.test.ts": 39, - "src/main/native-chat/structured-agent-session-history-adoption.test.ts": 141, - "src/main/native-chat/subagent-entry-id-bounds.test.ts": 37, - "src/main/native-chat/transcript-fallback-id.test.ts": 47, - "src/main/native-chat/transcript-interruption-message.test.ts": 50, - "src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 51, - "src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 81, - "src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts": 48, - "src/main/native-chat/transcript-line-decoders.grok.test.ts": 64, - "src/main/native-chat/transcript-line-decoders.omp.test.ts": 95, - "src/main/native-chat/transcript-read-cache-inflight.test.ts": 512, - "src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 135, - "src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 58, - "src/main/native-chat/transcript-read-cache.test.ts": 193, - "src/main/native-chat/transcript-reader-codex-history-mode.test.ts": 132, - "src/main/native-chat/transcript-reader-wsl-stall.test.ts": 114, - "src/main/native-chat/transcript-reader.test.ts": 176, - "src/main/native-chat/transcript-stream-lines.test.ts": 83, - "src/main/native-chat/transcript-tail-reader-cancellation.test.ts": 210, - "src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts": 91, - "src/main/native-chat/transcript-turn-lifecycle.test.ts": 95, - "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts": 217, - "src/main/native-chat/transcript-watch-error.test.ts": 2170, - "src/main/native-chat/transcript-watch-liveness.test.ts": 762, - "src/main/native-chat/transcript-watch-resolve-poll.test.ts": 203, - "src/main/native-chat/transcript-watch-unflushed-settle.test.ts": 201, - "src/main/native-chat/transcript-watch-unsubscribe-race.test.ts": 133, - "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts": 105, - "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 170, - "src/main/native-chat/transcript-watch.test.ts": 3388, - "src/main/native-chat/transcript-window-tool-attribution.test.ts": 146, - "src/main/native-chat/wsl-session-path-scan.test.ts": 100, - "src/main/native-chat/wsl-transcript-fs-access.test.ts": 147, - "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 538, - "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 79, - "src/main/native-chat/wsl-transcript-fs-process-operations.test.ts": 38, - "src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts": 76, + "src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts": 129, + "src/main/memory/hydrate-local-pty-registry.test.ts": 757, + "src/main/memory/memory-snapshot-buckets.test.ts": 104, + "src/main/memory/process-memory-metric.test.ts": 47, + "src/main/memory/verified-local-folder-workspaces.test.ts": 63, + "src/main/memory/windows-process-sample-parsing.test.ts": 105, + "src/main/menu/gpu-acceleration-about-panel.test.ts": 53, + "src/main/menu/register-app-menu.test.ts": 176, + "src/main/mimo/hook-service.test.ts": 120, + "src/main/minimax/minimax-api-key-store.test.ts": 107, + "src/main/minimax/minimax-cookie-store.test.ts": 113, + "src/main/muse-usage/scanner.test.ts": 218, + "src/main/muse-usage/snapshot-rollups.test.ts": 62, + "src/main/muse-usage/store-snapshot-filtering.test.ts": 705, + "src/main/muse/hook-config-json.test.ts": 81, + "src/main/muse/hook-service.test.ts": 135, + "src/main/muse/hook-settings.test.ts": 107, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts": 119, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.test.ts": 52, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts": 342, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts": 325, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts": 1592, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts": 87, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts": 299, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts": 609, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts": 103, + "src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts": 203, + "src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts": 637, + "src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts": 129, + "src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts": 664, + "src/main/native-chat/agent-session-journal/command-receipt-schema.test.ts": 1186, + "src/main/native-chat/agent-session-journal/command-receipt-table.test.ts": 994, + "src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts": 595, + "src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts": 215, + "src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts": 1761, + "src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts": 242, + "src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts": 626, + "src/main/native-chat/agent-session-journal/journal-cursor.test.ts": 58, + "src/main/native-chat/agent-session-journal/journal-damage.test.ts": 696, + "src/main/native-chat/agent-session-journal/journal-database.test.ts": 465, + "src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts": 366, + "src/main/native-chat/agent-session-journal/journal-dispatch-observation.test.ts": 57, + "src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts": 626, + "src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts": 261, + "src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts": 596, + "src/main/native-chat/agent-session-journal/journal-host-database.test.ts": 889, + "src/main/native-chat/agent-session-journal/journal-item-identity.test.ts": 76, + "src/main/native-chat/agent-session-journal/journal-legacy-import-source-bound.test.ts": 281, + "src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts": 479, + "src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts": 1038, + "src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.test.ts": 781, + "src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts": 13771, + "src/main/native-chat/agent-session-journal/journal-open-failure.test.ts": 384, + "src/main/native-chat/agent-session-journal/journal-payload-bounds-retention.test.ts": 337, + "src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts": 561, + "src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts": 513, + "src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts": 343, + "src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts": 339, + "src/main/native-chat/agent-session-journal/journal-reducer.test.ts": 439, + "src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts": 335, + "src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts": 846, + "src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts": 690, + "src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts": 524, + "src/main/native-chat/agent-session-journal/journal-row-schema.test.ts": 461, + "src/main/native-chat/agent-session-journal/journal-row-writer.test.ts": 605, + "src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts": 548, + "src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts": 349, + "src/main/native-chat/agent-session-journal/journal-store-schema.test.ts": 667, + "src/main/native-chat/agent-session-journal/journal-store.test.ts": 753, + "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts": 1439, + "src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts": 658, + "src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts": 588, + "src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts": 547, + "src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts": 749, + "src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts": 407, + "src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts": 753, + "src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts": 652, + "src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts": 742, + "src/main/native-chat/agent-session-journal/journal-write-queue.test.ts": 425, + "src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts": 663, + "src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts": 62, + "src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts": 638, + "src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts": 303, + "src/main/native-chat/agent-session-journal/queued-message-pause.test.ts": 808, + "src/main/native-chat/agent-session-journal/queued-message-store.test.ts": 1498, + "src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts": 346, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts": 577, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts": 460, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts": 483, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts": 840, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts": 704, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts": 802, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts": 484, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts": 857, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts": 773, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts": 1106, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts": 577, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts": 642, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts": 502, + "src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts": 64, + "src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts": 468, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-overflow-prefix.test.ts": 101, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts": 79, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts": 74, + "src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts": 440, + "src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts": 917, + "src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts": 1364, + "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts": 1062, + "src/main/native-chat/agent-session-wire/agent-session-history-page-grouping-parity.test.ts": 229, + "src/main/native-chat/agent-session-wire/agent-session-history-page-scaling.test.ts": 78, + "src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts": 1739, + "src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts": 853, + "src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts": 51, + "src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts": 91, + "src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts": 464, + "src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts": 2781, + "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts": 1351, + "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts": 891, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts": 355, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts": 391, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts": 233, + "src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts": 1223, + "src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts": 1257, + "src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts": 1891, + "src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts": 624, + "src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts": 1373, + "src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts": 349, + "src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts": 987, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts": 1595, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts": 1289, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts": 1404, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts": 4766, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts": 1212, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts": 4570, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts": 1253, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts": 3995, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts": 3545, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts": 1652, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts": 4314, + "src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts": 1453, + "src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts": 2367, + "src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts": 941, + "src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts": 1320, + "src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts": 1347, + "src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts": 3905, + "src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts": 1042, + "src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts": 459, + "src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts": 1873, + "src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts": 3529, + "src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts": 746, + "src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts": 1635, + "src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts": 992, + "src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts": 1433, + "src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts": 1584, + "src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts": 843, + "src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts": 201, + "src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts": 985, + "src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts": 876, + "src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts": 434, + "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts": 1316, + "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts": 1186, + "src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts": 223, + "src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts": 40, + "src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts": 1449, + "src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts": 1196, + "src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts": 580, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts": 915, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts": 699, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts": 473, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts": 1168, + "src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts": 1737, + "src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts": 2591, + "src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts": 3775, + "src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts": 957, + "src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts": 486, + "src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts": 1481, + "src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts": 355, + "src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts": 1218, + "src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts": 706, + "src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts": 1500, + "src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts": 1117, + "src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts": 190, + "src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts": 1078, + "src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts": 1098, + "src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts": 1244, + "src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts": 1201, + "src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts": 519, + "src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts": 1248, + "src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts": 365, + "src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts": 198, + "src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts": 3318, + "src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts": 917, + "src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts": 1125, + "src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel-repeat.test.ts": 782, + "src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts": 914, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts": 3062, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts": 1138, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts": 758, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts": 1956, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts": 5719, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts": 1912, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts": 5019, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts": 1908, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts": 1540, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts": 5700, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts": 1096, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts": 6327, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.test.ts": 957, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts": 1221, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts": 1544, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts": 4507, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts": 1569, + "src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts": 628, + "src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts": 543, + "src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts": 461, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts": 1364, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts": 1017, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 4093, + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts": 360, + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts": 1144, + "src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts": 314, + "src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts": 47, + "src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts": 999, + "src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts": 1072, + "src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts": 1207, + "src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts": 2268, + "src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts": 1204, + "src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts": 235, + "src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts": 576, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts": 1011, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts": 686, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts": 1345, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts": 399, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts": 242, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts": 518, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts": 1326, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts": 1335, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts": 1285, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts": 1212, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts": 2691, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts": 2611, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts": 2206, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts": 269, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts": 1503, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts": 67, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts": 649, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts": 1297, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts": 632, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts": 1232, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts": 1339, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts": 1379, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts": 959, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts": 434, + "src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts": 427, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts": 1079, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts": 1097, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts": 2657, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts": 280, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts": 796, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts": 1279, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts": 3251, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts": 2021, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts": 337, + "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts": 2285, + "src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts": 1282, + "src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts": 478, + "src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts": 1364, + "src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts": 1452, + "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts": 1043, + "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts": 1011, + "src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts": 1029, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts": 3748, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts": 2188, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts": 64, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts": 392, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts": 1817, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts": 570, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts": 454, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts": 643, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts": 787, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts": 595, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts": 649, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts": 524, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts": 904, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts": 470, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts": 251, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts": 1002, + "src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts": 1701, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts": 890, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts": 2907, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts": 5698, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts": 1979, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts": 2801, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts": 1766, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts": 701, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts": 513, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts": 1319, + "src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts": 457, + "src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts": 3973, + "src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts": 1318, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts": 643, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts": 5915, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts": 1011, + "src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts": 2430, + "src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts": 98, + "src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts": 847, + "src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts": 829, + "src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts": 328, + "src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts": 1030, + "src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts": 276, + "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 480, + "src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts": 195, + "src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts": 968, + "src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts": 1496, + "src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts": 5952, + "src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts": 1354, + "src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts": 1592, + "src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts": 922, + "src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts": 326, + "src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts": 939, + "src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts": 196, + "src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts": 1502, + "src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts": 2102, + "src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts": 273, + "src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts": 234, + "src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts": 332, + "src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 359, + "src/main/native-chat/host-readable-transcript-path.test.ts": 160, + "src/main/native-chat/native-chat-file-provenance.test.ts": 575, + "src/main/native-chat/native-chat-inline-visuals-setting.test.ts": 536, + "src/main/native-chat/native-chat-visual-file-read.test.ts": 85, + "src/main/native-chat/native-chat-visuals-delivery.test.ts": 343, + "src/main/native-chat/native-chat-visuals-sweep.test.ts": 78, + "src/main/native-chat/session-file-resolver-claude-roots.test.ts": 142, + "src/main/native-chat/session-file-resolver-codex-roots.test.ts": 136, + "src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 141, + "src/main/native-chat/session-file-resolver-wsl.test.ts": 139, + "src/main/native-chat/session-file-resolver.test.ts": 217, + "src/main/native-chat/ssh-transcript-path.test.ts": 40, + "src/main/native-chat/structured-agent-command-resolution.test.ts": 86, + "src/main/native-chat/structured-agent-configured-args.test.ts": 48, + "src/main/native-chat/structured-agent-session-create-support.test.ts": 54, + "src/main/native-chat/structured-agent-session-history-adoption.test.ts": 279, + "src/main/native-chat/structured-chat-naming-command-first.test.ts": 1258, + "src/main/native-chat/structured-chat-naming-status-hook.test.ts": 2387, + "src/main/native-chat/structured-chat-naming.test.ts": 225, + "src/main/native-chat/subagent-entry-id-bounds.test.ts": 51, + "src/main/native-chat/subagent-roster-retention.test.ts": 88, + "src/main/native-chat/transcript-fallback-id.test.ts": 52, + "src/main/native-chat/transcript-file-version.test.ts": 67, + "src/main/native-chat/transcript-interruption-message.test.ts": 100, + "src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts": 96, + "src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 104, + "src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 102, + "src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts": 108, + "src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts": 97, + "src/main/native-chat/transcript-line-decoders.grok.test.ts": 132, + "src/main/native-chat/transcript-line-decoders.omp.test.ts": 142, + "src/main/native-chat/transcript-opencode-database.test.ts": 107, + "src/main/native-chat/transcript-opencode-read-bounds.test.ts": 589, + "src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts": 2910, + "src/main/native-chat/transcript-opencode-sqlite-query.test.ts": 594, + "src/main/native-chat/transcript-opencode-subscribe.test.ts": 5611, + "src/main/native-chat/transcript-opencode.test.ts": 405, + "src/main/native-chat/transcript-opencode2-sqlite.test.ts": 10052, + "src/main/native-chat/transcript-read-cache-inflight.test.ts": 1054, + "src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 580, + "src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 758, + "src/main/native-chat/transcript-read-cache.test.ts": 652, + "src/main/native-chat/transcript-reader-codex-history-mode.test.ts": 636, + "src/main/native-chat/transcript-reader-wsl-stall.test.ts": 637, + "src/main/native-chat/transcript-reader.test.ts": 607, + "src/main/native-chat/transcript-record-copy-counts.test.ts": 487, + "src/main/native-chat/transcript-stream-lines.test.ts": 103, + "src/main/native-chat/transcript-tail-reader-cancellation.test.ts": 701, + "src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts": 611, + "src/main/native-chat/transcript-tool-result-identity.test.ts": 537, + "src/main/native-chat/transcript-turn-lifecycle.test.ts": 105, + "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts": 730, + "src/main/native-chat/transcript-watch-error.test.ts": 2634, + "src/main/native-chat/transcript-watch-inplace-drain-race.test.ts": 960, + "src/main/native-chat/transcript-watch-liveness.test.ts": 1270, + "src/main/native-chat/transcript-watch-resolve-poll.test.ts": 784, + "src/main/native-chat/transcript-watch-unflushed-settle.test.ts": 820, + "src/main/native-chat/transcript-watch-unsubscribe-race.test.ts": 624, + "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts": 594, + "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 693, + "src/main/native-chat/transcript-watch.test.ts": 2600, + "src/main/native-chat/transcript-window-tool-attribution.test.ts": 616, + "src/main/native-chat/wsl-session-path-scan.test.ts": 107, + "src/main/native-chat/wsl-transcript-fs-access.test.ts": 161, + "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 585, + "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 115, + "src/main/native-chat/wsl-transcript-fs-process-operations.test.ts": 55, + "src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts": 98, "src/main/native-chat/wsl-transcript-fs-route.test.ts": 44, - "src/main/native-chat/wsl-transcript-running-observer.test.ts": 119, - "src/main/network/electron-proxy-request-guard.test.ts": 351, - "src/main/network/macos-system-resolver-health.test.ts": 74, - "src/main/network/macos-tailscale-dns-diagnostic.test.ts": 47, - "src/main/network/proxy-settings-session.test.ts": 837, - "src/main/network/proxy-settings.test.ts": 90, - "src/main/notebook/notebook-kernel.test.ts": 160, - "src/main/notebook/python-environments.test.ts": 82, - "src/main/notifications/announced-notification-registry.test.ts": 38, - "src/main/notifications/desktop-away-state.test.ts": 54, - "src/main/notifications/notification-delivery-service.test.ts": 69, - "src/main/observability/architecture.test.ts": 56, - "src/main/observability/bundle.test.ts": 471, - "src/main/observability/diagnostic-upload-http.test.ts": 50, - "src/main/observability/instrumentation.test.ts": 472, - "src/main/observability/local-file-sink-memory.test.ts": 149, - "src/main/observability/local-file-sink.test.ts": 84, - "src/main/observability/redactor-environment-lines.test.ts": 546, - "src/main/observability/redactor.test.ts": 74, - "src/main/observability/tracer.test.ts": 74, - "src/main/opencode-usage/cache-read-reproduction.test.ts": 173, - "src/main/opencode-usage/opencode2-session-usage.test.ts": 528, - "src/main/opencode-usage/scanner-windows-data-directory.test.ts": 164, - "src/main/opencode-usage/scanner-wsl-gate.test.ts": 49, - "src/main/opencode-usage/scanner.test.ts": 182, - "src/main/opencode-usage/store.test.ts": 141, - "src/main/opencode/hook-plugin-background-child-completion.test.ts": 405, - "src/main/opencode/hook-plugin-child-attention.test.ts": 698, - "src/main/opencode/hook-plugin-fail-open-ownership.test.ts": 519, - "src/main/opencode/hook-plugin-lifecycle-delivery.test.ts": 1151, - "src/main/opencode/hook-plugin-message-part-throttle.test.ts": 306, - "src/main/opencode/hook-plugin-module-contract.test.ts": 601, - "src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts": 1056, - "src/main/opencode/hook-plugin-opencode2-setup.test.ts": 1712, - "src/main/opencode/hook-service.test.ts": 275, - "src/main/opencode/opencode-client-sweep.test.ts": 66, - "src/main/opencode/opencode-data-directory.test.ts": 51, + "src/main/native-chat/wsl-transcript-running-observer.test.ts": 104, + "src/main/nested-worktree-removal-plan.test.ts": 87, + "src/main/network/electron-proxy-request-guard.test.ts": 381, + "src/main/network/macos-system-resolver-health.test.ts": 71, + "src/main/network/macos-tailscale-dns-diagnostic.test.ts": 58, + "src/main/network/macos-tailscale-dns-probe-admission.test.ts": 225, + "src/main/network/proxy-settings-session.test.ts": 883, + "src/main/network/proxy-settings.test.ts": 120, + "src/main/notebook/notebook-kernel.test.ts": 148, + "src/main/notebook/python-environments.test.ts": 126, + "src/main/notifications/announced-notification-registry.test.ts": 253, + "src/main/notifications/desktop-away-state.test.ts": 62, + "src/main/notifications/notification-delivery-service.test.ts": 87, + "src/main/observability/bundle.test.ts": 487, + "src/main/observability/diagnostic-upload-http.test.ts": 48, + "src/main/observability/instrumentation.test.ts": 235, + "src/main/observability/local-file-sink-memory.test.ts": 135, + "src/main/observability/local-file-sink.test.ts": 81, + "src/main/observability/redactor-environment-lines.test.ts": 217, + "src/main/observability/redactor.test.ts": 86, + "src/main/observability/tracer.test.ts": 91, + "src/main/opencode-usage/cache-read-reproduction.test.ts": 244, + "src/main/opencode-usage/cache-write-accounting.test.ts": 197, + "src/main/opencode-usage/opencode2-session-usage.test.ts": 906, + "src/main/opencode-usage/scanner-windows-data-directory.test.ts": 207, + "src/main/opencode-usage/scanner-wsl-gate.test.ts": 98, + "src/main/opencode-usage/scanner.test.ts": 349, + "src/main/opencode-usage/store.test.ts": 424, + "src/main/opencode/hook-plugin-background-child-completion.test.ts": 375, + "src/main/opencode/hook-plugin-child-attention.test.ts": 759, + "src/main/opencode/hook-plugin-dispose-host.test.ts": 404, + "src/main/opencode/hook-plugin-fail-open-ownership.test.ts": 553, + "src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts": 2096, + "src/main/opencode/hook-plugin-lifecycle-delivery.test.ts": 1063, + "src/main/opencode/hook-plugin-message-part-throttle.test.ts": 266, + "src/main/opencode/hook-plugin-module-contract.test.ts": 682, + "src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts": 1037, + "src/main/opencode/hook-plugin-opencode2-setup.test.ts": 2250, + "src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts": 1275, + "src/main/opencode/hook-plugin-run-process-session-start.test.ts": 183, + "src/main/opencode/hook-service-invalid-tui-config.test.ts": 111, + "src/main/opencode/hook-service-legacy-acl.test.ts": 251, + "src/main/opencode/hook-service-overlay-confinement.test.ts": 113, + "src/main/opencode/hook-service-startup-refresh.test.ts": 89, + "src/main/opencode/hook-service.test.ts": 350, + "src/main/opencode/legacy-shared-config-dir.test.ts": 54, + "src/main/opencode/opencode-acp-stored-messages.test.ts": 134, + "src/main/opencode/opencode-cli-version.test.ts": 53, + "src/main/opencode/opencode-client-sweep.test.ts": 119, + "src/main/opencode/opencode-credential-backend.test.ts": 79, + "src/main/opencode/opencode-data-directory.test.ts": 50, + "src/main/opencode/opencode-go-api-key-store.test.ts": 97, + "src/main/opencode/opencode-launch-capabilities.test.ts": 67, + "src/main/opencode/opencode-launch-model-config.test.ts": 217, + "src/main/opencode/opencode-launch-model-context-cleanup.test.ts": 4256, + "src/main/opencode/opencode-launch-model-context-supervision.test.ts": 2443, + "src/main/opencode/opencode-launch-model-context.test.ts": 201, + "src/main/opencode/opencode-model-availability.test.ts": 71, + "src/main/opencode/opencode-model-catalog-listing.test.ts": 190, + "src/main/opencode/opencode-model-startup-plan.test.ts": 263, + "src/main/opencode/opencode-model-version-policy.test.ts": 41, + "src/main/opencode/opencode-overlay-manifest.test.ts": 39, + "src/main/opencode/opencode-pty-launch.test.ts": 321, "src/main/opencode/opencode-session-binder.test.ts": 118, - "src/main/opencode2/status-plugin-setup-lifetime.test.ts": 51, - "src/main/orca-profiles/profile-active-transfer-worker.test.ts": 2219, - "src/main/orca-profiles/profile-active-transfer.test.ts": 1956, - "src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts": 371, - "src/main/orca-profiles/profile-cloud-auth-config.test.ts": 57, - "src/main/orca-profiles/profile-cloud-auth-status.test.ts": 66, - "src/main/orca-profiles/profile-cloud-client.test.ts": 127, - "src/main/orca-profiles/profile-cloud-dev-service.test.ts": 328, - "src/main/orca-profiles/profile-cloud-org-members-client.test.ts": 68, - "src/main/orca-profiles/profile-cloud-org-members-service.test.ts": 197, - "src/main/orca-profiles/profile-cloud-pkce.test.ts": 620, - "src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts": 53, - "src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts": 296, - "src/main/orca-profiles/profile-cloud-service-connect-overlap.test.ts": 311, - "src/main/orca-profiles/profile-cloud-service-refresh.test.ts": 315, - "src/main/orca-profiles/profile-cloud-service-sign-out-connect-race.test.ts": 378, - "src/main/orca-profiles/profile-cloud-service.test.ts": 445, - "src/main/orca-profiles/profile-cloud-session-mutation.test.ts": 95, - "src/main/orca-profiles/profile-cloud-session-refresh.test.ts": 97, - "src/main/orca-profiles/profile-cloud-session-store.test.ts": 130, - "src/main/orca-profiles/profile-index-store.test.ts": 700, - "src/main/orca-profiles/profile-persistence-deadline.test.ts": 39, - "src/main/orca-profiles/profile-project-domain-transfer.test.ts": 1889, - "src/main/orca-profiles/profile-project-presence.test.ts": 408, - "src/main/orca-profiles/profile-project-session-field-disposition.test.ts": 190, - "src/main/orca-profiles/profile-project-session-state.test.ts": 175, - "src/main/orca-profiles/profile-project-state-file.test.ts": 264, - "src/main/orca-profiles/profile-project-transfer-migration.test.ts": 1155, - "src/main/orca-profiles/profile-project-transfer.test.ts": 1755, - "src/main/orca-profiles/profile-telemetry-consent-seed.test.ts": 221, - "src/main/orca-profiles/profile-ui-scope.test.ts": 28, - "src/main/orcad/electron-serve-browser-process.test.ts": 2507, - "src/main/orcad/electron-serve-provider-selection.test.ts": 696, - "src/main/orcad/electron-sidecar-method-routing.test.ts": 105, - "src/main/orcad/electron-sidecar-tab-registry.test.ts": 78, - "src/main/orcad/external-chromium-browser-process.integration.test.ts": 233, - "src/main/orcad/external-chromium-browser-session.test.ts": 172, - "src/main/orcad/main-preflight-order.test.ts": 106, - "src/main/orcad/native-host-abi.test.ts": 58, - "src/main/orcad/node-pty-prebuilt-slot.test.ts": 64, - "src/main/orcad/node-pty-precondition.test.ts": 310, - "src/main/orcad/orcad-app-paths.test.ts": 73, - "src/main/orcad/orcad-artifact-preflight.test.ts": 377, - "src/main/orcad/orcad-bind-address.test.ts": 57, - "src/main/orcad/orcad-browser-provider.test.ts": 336, - "src/main/orcad/orcad-bundle-native-load-order.test.ts": 2639, - "src/main/orcad/orcad-bundled-runtime.integration.test.ts": 5450, - "src/main/orcad/orcad-bundled-runtime.test.ts": 90, - "src/main/orcad/orcad-daemon-supervision.test.ts": 35, - "src/main/orcad/orcad-entry.test.ts": 287, - "src/main/orcad/orcad-forked-child-paths.test.ts": 352, - "src/main/orcad/orcad-health.test.ts": 49, - "src/main/orcad/orcad-instance-lock.test.ts": 91, - "src/main/orcad/orcad-launch-contract.test.ts": 405, - "src/main/orcad/orcad-native-preflight.test.ts": 71, - "src/main/orcad/orcad-node-launcher.integration.test.ts": 50, - "src/main/orcad/orcad-profile-preflight.test.ts": 246, - "src/main/orcad/orcad-profile-state-startup.test.ts": 55, - "src/main/orcad/orcad-profile-state-telemetry.test.ts": 36, - "src/main/orcad/orcad-push-startup.test.ts": 1287, - "src/main/orcad/orcad-runtime-native-preflight.test.ts": 113, - "src/main/orcad/orcad-sidecar-runtime-client.test.ts": 211, - "src/main/own-chromium-tree-kill-guard.test.ts": 99, - "src/main/persistence-async-write-syscalls.test.ts": 3167, - "src/main/persistence-automations.test.ts": 2205, - "src/main/persistence-clipboard-selection-migration.test.ts": 1235, - "src/main/persistence-cohort-and-identity-migration.test.ts": 1307, - "src/main/persistence-cross-host-pane-identity.test.ts": 1023, - "src/main/persistence-deregistered-repo-residue.test.ts": 1319, - "src/main/persistence-duplicate-repo-id-host-scope.test.ts": 1500, - "src/main/persistence-feature-interaction-broadcast.benchmark.test.ts": 1424, - "src/main/persistence-floating-terminal-trust.test.ts": 1342, - "src/main/persistence-flush-and-save-scheduling.test.ts": 3251, - "src/main/persistence-folder-workspace-notes.test.ts": 1466, - "src/main/persistence-host-admitted-terminal-membership.test.ts": 1200, - "src/main/persistence-host-partitioned-sessions.test.ts": 1016, - "src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts": 1205, - "src/main/persistence-initial-load.test.ts": 1188, - "src/main/persistence-layout-binding-recovery.test.ts": 1127, - "src/main/persistence-load-repair-durability.test.ts": 693, - "src/main/persistence-loading-store-extraction.test.ts": 1319, - "src/main/persistence-loading-store-write-risks.test.ts": 1386, - "src/main/persistence-native-chat-tab-view-mode.test.ts": 1121, - "src/main/persistence-pane-identity-migration.test.ts": 28713, - "src/main/persistence-protected-secret-fail-closed.test.ts": 5578, - "src/main/persistence-protected-secret-write-race.test.ts": 1294, - "src/main/persistence-proxy-secret-recovery.test.ts": 1954, - "src/main/persistence-pty-binding-leaf-tab-resolution.test.ts": 938, - "src/main/persistence-pty-binding-reconciliation.test.ts": 1174, - "src/main/persistence-remote-session-startup.test.ts": 1128, - "src/main/persistence-repo-lifecycle.test.ts": 3659, - "src/main/persistence-right-sidebar-tab.test.ts": 1040, - "src/main/persistence-settings-ui-defaults.test.ts": 1660, - "src/main/persistence-settings-update.test.ts": 1245, - "src/main/persistence-single-serialize.test.ts": 2736, - "src/main/persistence-source-control-ai-migration.test.ts": 683, - "src/main/persistence-split-pane-incarnation.test.ts": 1213, - "src/main/persistence-ssh-lease-reattach-reclaim.test.ts": 1164, - "src/main/persistence-ssh-lease-tombstone-retention.test.ts": 1178, - "src/main/persistence-ssh-pending-pty-kill.test.ts": 1764, - "src/main/persistence-ssh-readoption-automation-migration.test.ts": 1863, - "src/main/persistence-ssh-remote-pty-binding-replay.test.ts": 799, - "src/main/persistence-ssh-remote-pty-leases.test.ts": 1111, - "src/main/persistence-ssh-targets-and-pane-keys.test.ts": 1001, - "src/main/persistence-terminal-option-key-migration.test.ts": 1316, - "src/main/persistence-ui-state.test.ts": 3909, - "src/main/persistence-update-repo.test.ts": 1077, - "src/main/persistence-workspace-pinned-automation-fence.test.ts": 1833, - "src/main/persistence-workspace-repin-automation-owner.test.ts": 1750, - "src/main/persistence-workspace-session-scrollback.test.ts": 1387, - "src/main/persistence-workspace-status-workflow.test.ts": 1055, - "src/main/persistence-worktree-card-properties.test.ts": 576, - "src/main/persistence-worktree-deletion-fencing.test.ts": 1370, - "src/main/persistence-worktree-lineage-and-backups.test.ts": 1778, - "src/main/persistence-worktree-meta-and-folder-workspaces.test.ts": 818, - "src/main/persistence-worktree-name-retirement.test.ts": 6044, - "src/main/persistence-worktree-visibility.test.ts": 1446, - "src/main/persistence/applying-settings/settings-update-machine-name.test.ts": 235, - "src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts": 177, - "src/main/persistence/applying-settings/terminal-settings-migrations.test.ts": 55, - "src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.test.ts": 221, - "src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts": 356, - "src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts": 261, - "src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts": 228, - "src/main/persistence/loading-store/persisted-state-redundancy.test.ts": 1195, - "src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts": 1418, - "src/main/persistence/loading-store/profile-state-checkpoints.test.ts": 1468, - "src/main/persistence/loading-store/profile-state-direct-flush.test.ts": 776, - "src/main/persistence/loading-store/profile-state-import-lifetime.test.ts": 882, - "src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts": 2555, - "src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts": 1334, - "src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts": 1797, - "src/main/persistence/loading-store/profile-state-maintenance.test.ts": 2795, - "src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts": 3208, - "src/main/persistence/loading-store/profile-state-selective-serialization.test.ts": 216, - "src/main/persistence/loading-store/profile-state-settings-writes.test.ts": 1551, - "src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts": 8170, - "src/main/persistence/loading-store/profile-state-store-backups.test.ts": 3107, - "src/main/persistence/loading-store/profile-state-temp-cleanup-lifetime.test.ts": 1081, - "src/main/persistence/loading-store/profile-state-update-quit.test.ts": 1347, - "src/main/persistence/loading-store/profile-state-worker-coordination.test.ts": 1583, - "src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts": 1293, - "src/main/persistence/loading-store/profile-state-write-batching.test.ts": 1364, - "src/main/persistence/loading-store/pty-binding-async-durability.test.ts": 1459, - "src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts": 806, - "src/main/persistence/loading-store/pty-binding-fast-lane.test.ts": 168, - "src/main/persistence/loading-store/pty-binding-span.test.ts": 104, - "src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts": 3450, - "src/main/persistence/loading-store/pty-retirement-async-durability.test.ts": 893, - "src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts": 2239, - "src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts": 4488, - "src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts": 3961, - "src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts": 4045, - "src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts": 4185, - "src/main/persistence/loading-store/secret-sentinel-substitution.test.ts": 92, - "src/main/persistence/loading-store/ssh-lease-async-durability.test.ts": 914, - "src/main/persistence/loading-store/state-write-round-trip.test.ts": 1233, - "src/main/persistence/loading-store/store-prune-gate-signals.test.ts": 1151, - "src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts": 1062, - "src/main/persistence/loading-store/terminal-tab-pty-ownership.test.ts": 35, - "src/main/persistence/loading-store/workspace-session-partitions.test.ts": 252, - "src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts": 68, - "src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts": 1809, - "src/main/persistence/loading-store/worktree-meta-write-normalization.test.ts": 68, - "src/main/persistence/profile-state-cutover-fixture.test.ts": 1259, - "src/main/persistence/profile-state-cutover-soak.test.ts": 1782, - "src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.test.ts": 122, - "src/main/persistence/profile-state/profile-state-access-identity.test.ts": 125, - "src/main/persistence/profile-state/profile-state-access-process.test.ts": 706, - "src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts": 50, - "src/main/persistence/profile-state/profile-state-access-windows.test.ts": 181, - "src/main/persistence/profile-state/profile-state-access.test.ts": 218, - "src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts": 2390, - "src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts": 187, - "src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts": 206, - "src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts": 225, - "src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts": 2866, - "src/main/persistence/profile-state/profile-state-backup-path.test.ts": 40, - "src/main/persistence/profile-state/profile-state-backup-rotation.test.ts": 519, - "src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts": 76, - "src/main/persistence/profile-state/profile-state-backup-worker.test.ts": 2514, - "src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts": 1143, - "src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts": 324, - "src/main/persistence/profile-state/profile-state-crash-recovery.test.ts": 442, - "src/main/persistence/profile-state/profile-state-current-json-command.test.ts": 2724, - "src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts": 1546, - "src/main/persistence/profile-state/profile-state-database-export-crash.test.ts": 221, - "src/main/persistence/profile-state/profile-state-database-recovery.test.ts": 1228, - "src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts": 1283, - "src/main/persistence/profile-state/profile-state-database-snapshot.test.ts": 1947, - "src/main/persistence/profile-state/profile-state-database.test.ts": 1004, - "src/main/persistence/profile-state/profile-state-documents.test.ts": 758, - "src/main/persistence/profile-state/profile-state-domain-equality.test.ts": 197, - "src/main/persistence/profile-state/profile-state-domain-reader.test.ts": 134, - "src/main/persistence/profile-state/profile-state-domain-writes.test.ts": 354, - "src/main/persistence/profile-state/profile-state-fragment-validation.test.ts": 978, - "src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts": 1250, - "src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts": 3070, - "src/main/persistence/profile-state/profile-state-live-store-factory.test.ts": 1747, - "src/main/persistence/profile-state/profile-state-offline-settings.test.ts": 286, - "src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts": 367, - "src/main/persistence/profile-state/profile-state-read-concurrency.test.ts": 112, - "src/main/persistence/profile-state/profile-state-recovery-batch.test.ts": 142, - "src/main/persistence/profile-state/profile-state-recovery-copy.test.ts": 92, - "src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts": 34081, - "src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts": 567, - "src/main/persistence/profile-state/profile-state-startup-authority.test.ts": 2198, - "src/main/persistence/profile-state/profile-state-startup-failure.test.ts": 1080, - "src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts": 95, - "src/main/persistence/profile-state/profile-state-startup-secrets.test.ts": 1403, - "src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts": 1390, - "src/main/persistence/profile-state/profile-state-store-factory.test.ts": 2255, - "src/main/persistence/profile-state/profile-state-streaming-validation.test.ts": 246, - "src/main/persistence/profile-state/profile-state-worker-authority.test.ts": 1304, - "src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts": 905, - "src/main/persistence/profile-state/profile-state-write-transaction.test.ts": 42, - "src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts": 2015, - "src/main/persistence/profile-state/profile-state-writer-worker.test.ts": 1074, - "src/main/persistence/restoring-sessions/pane-alias-normalization-scaling.test.ts": 70, - "src/main/persistence/restoring-sessions/pane-alias-normalization.test.ts": 399, - "src/main/persistence/restoring-sessions/pane-key-remapping.test.ts": 26, - "src/main/persistence/restoring-sessions/session-owner-fields.test.ts": 141, - "src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts": 205, - "src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts": 53, - "src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts": 69, - "src/main/persistence/runtime-authored-workspace-session-fields.test.ts": 51, - "src/main/persistence/scheduling-automations/automation-run-operations.test.ts": 62, - "src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts": 248, - "src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts": 152, - "src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts": 301, - "src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts": 218, - "src/main/persistence/tracking-repos/project-host-compatibility.test.ts": 36, - "src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts": 147, - "src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts": 182, - "src/main/pi/agent-status-completion-delivery.test.ts": 899, - "src/main/pi/agent-status-extension-async-subagents.test.ts": 1070, - "src/main/pi/agent-status-extension-nested-run-settlement.test.ts": 588, - "src/main/pi/agent-status-extension-omp-lifecycle.test.ts": 2513, - "src/main/pi/agent-status-extension-omp-model.test.ts": 1180, - "src/main/pi/agent-status-extension-omp-prefill.test.ts": 615, - "src/main/pi/agent-status-extension-source.test.ts": 2899, - "src/main/pi/agent-status-input-redaction.test.ts": 1930, - "src/main/pi/agent-status-omp-approval-forwarding.test.ts": 1049, - "src/main/pi/agent-status-owner-recovery.test.ts": 1135, - "src/main/pi/agent-status-ui-prompt.test.ts": 1344, - "src/main/pi/omp-model-command-source.test.ts": 619, - "src/main/pi/omp-session-status-owner.test.ts": 780, - "src/main/pi/omp-transcript-metadata.test.ts": 1593, - "src/main/pi/prefill-extension-source.test.ts": 36, - "src/main/pi/titlebar-extension-overlay-path.test.ts": 92, - "src/main/pi/titlebar-extension-service.test.ts": 524, - "src/main/pi/titlebar-extension-source.test.ts": 1416, - "src/main/plugins/plugin-agent-status-event.test.ts": 86, - "src/main/plugins/plugin-atomic-file-write.test.ts": 2056, - "src/main/plugins/plugin-audit-log-scaling.test.ts": 497, - "src/main/plugins/plugin-audit-log.test.ts": 73, - "src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts": 364, - "src/main/plugins/plugin-bundled-bootstrap.test.ts": 468, - "src/main/plugins/plugin-command-registry.test.ts": 271, - "src/main/plugins/plugin-content-pack-registry.test.ts": 303, - "src/main/plugins/plugin-content-safety.test.ts": 716, - "src/main/plugins/plugin-dev-watcher.test.ts": 165, - "src/main/plugins/plugin-discovery.test.ts": 193, - "src/main/plugins/plugin-enablement.test.ts": 194, - "src/main/plugins/plugin-host-conformance.test.ts": 202, - "src/main/plugins/plugin-host-methods.test.ts": 173, - "src/main/plugins/plugin-host-process.test.ts": 208, - "src/main/plugins/plugin-host-runtime.test.ts": 193, - "src/main/plugins/plugin-install-trust.test.ts": 378, - "src/main/plugins/plugin-install.test.ts": 787, - "src/main/plugins/plugin-kill-list-content-revocation.test.ts": 321, - "src/main/plugins/plugin-kill-list-service.test.ts": 239, - "src/main/plugins/plugin-language-pack-registry.test.ts": 179, - "src/main/plugins/plugin-launch-content.test.ts": 470, - "src/main/plugins/plugin-list-projection.test.ts": 145, - "src/main/plugins/plugin-marketplace-installer.test.ts": 480, - "src/main/plugins/plugin-marketplace-service.test.ts": 369, - "src/main/plugins/plugin-marketplace-store.test.ts": 135, - "src/main/plugins/plugin-panel-controller.test.ts": 210, - "src/main/plugins/plugin-panel-navigation-guard.test.ts": 140, - "src/main/plugins/plugin-panel-owner-lifecycle.test.ts": 54, - "src/main/plugins/plugin-panel-sessions.test.ts": 50, - "src/main/plugins/plugin-private-marketplace.integration.test.ts": 685, - "src/main/plugins/plugin-secrets-store.test.ts": 219, - "src/main/plugins/plugin-service-integrity.test.ts": 408, - "src/main/plugins/plugin-service-reconciliation.test.ts": 597, - "src/main/plugins/plugin-startup-budget.test.ts": 768, - "src/main/plugins/plugin-storage-store.test.ts": 198, - "src/main/plugins/plugin-uninstall-lifecycle.test.ts": 740, - "src/main/plugins/plugin-uninstall-log-retention.test.ts": 974, - "src/main/plugins/plugin-uninstall-ordering.test.ts": 967, - "src/main/plugins/plugin-vm-recipe-registry.test.ts": 236, - "src/main/plugins/plugin-worker-controller.test.ts": 393, - "src/main/plugins/plugin-worker-env.test.ts": 35, - "src/main/plugins/plugin-worker-manager.test.ts": 165, - "src/main/plugins/plugin-worker-output-buffer.test.ts": 54, - "src/main/plugins/plugin-worker-output-retention.test.ts": 229, - "src/main/plugins/plugin-worker-supervision.integration.test.ts": 8081, - "src/main/ports/advertised-url-retention.test.ts": 163, - "src/main/ports/advertised-url-watcher-pid-validation.test.ts": 53, - "src/main/ports/advertised-url-watcher.test.ts": 59, - "src/main/ports/local-workspace-port-scanner.test.ts": 119, - "src/main/ports/port-scan-command-client.test.ts": 1765, - "src/main/ports/port-scan-command-execution.test.ts": 5284, - "src/main/ports/port-scan-command-import-boundary.test.ts": 39, - "src/main/ports/ssh-advertised-url-enrichment.test.ts": 96, - "src/main/ports/workspace-port-ownership.test.ts": 36, - "src/main/powershell-osc133-bootstrap-windows-clm.test.ts": 84, - "src/main/powershell-osc133-bootstrap.test.ts": 81, - "src/main/project-groups/folder-workspace-path-status.test.ts": 72, - "src/main/project-groups/nested-repo-discovery-queue.test.ts": 201, - "src/main/project-groups/nested-repo-discovery.test.ts": 177, - "src/main/project-groups/nested-repo-glob-budget.test.ts": 841, - "src/main/project-groups/nested-repo-import-target.test.ts": 58, - "src/main/project-groups/nested-repo-import.test.ts": 58, - "src/main/project-groups/nested-repo-scan-rules.test.ts": 31, - "src/main/project-runtime-git-options.test.ts": 73, - "src/main/protected-secret-persistence-concurrency.test.ts": 68, - "src/main/protected-secret-persistence.test.ts": 50, - "src/main/providers/agent-foreground-process-batch.test.ts": 91, - "src/main/providers/agent-foreground-process-git-bash-launcher.test.ts": 87, - "src/main/providers/agent-foreground-process-git-bash.win32.test.ts": 196, - "src/main/providers/agent-foreground-process-pi.test.ts": 82, - "src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 105, - "src/main/providers/agent-foreground-process-real-rows.test.ts": 105, - "src/main/providers/agent-foreground-process-remote-evidence.test.ts": 81, - "src/main/providers/agent-foreground-process-shell-path.test.ts": 76, - "src/main/providers/agent-foreground-process-windows-job-rejection.test.ts": 48, - "src/main/providers/agent-foreground-process-wsl-shell-proof.test.ts": 108, - "src/main/providers/agent-foreground-process.test.ts": 149, - "src/main/providers/execution-host-provider-dispatch.test.ts": 33, - "src/main/providers/local-pty-child-process-verdict.test.ts": 280, - "src/main/providers/local-pty-default-shell.test.ts": 124, - "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 140, - "src/main/providers/local-pty-pending-native-spawn.test.ts": 281, - "src/main/providers/local-pty-pi-owner-env.test.ts": 108, - "src/main/providers/local-pty-provider-foreground-process.test.ts": 413, - "src/main/providers/local-pty-provider-io-events.test.ts": 295, - "src/main/providers/local-pty-provider-session-inventory.test.ts": 323, - "src/main/providers/local-pty-provider-shell-readiness.test.ts": 240, - "src/main/providers/local-pty-provider-shutdown.test.ts": 652, - "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 305, - "src/main/providers/local-pty-provider-spawn-env.test.ts": 308, - "src/main/providers/local-pty-provider-spawn-session.test.ts": 410, - "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 301, - "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 61, - "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 66, - "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 544, - "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 253, - "src/main/providers/local-pty-utils-windows-fallback.test.ts": 81, - "src/main/providers/local-pty-utils.test.ts": 81, - "src/main/providers/macos-login-session-pty-probe.test.ts": 42, - "src/main/providers/macos-tcc-login-shell.test.ts": 146, - "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 42, - "src/main/providers/process-cwd.test.ts": 141, - "src/main/providers/provider-dispatch.test.ts": 1559, - "src/main/providers/pty-default-cwd.test.ts": 58, - "src/main/providers/pty-process-inspection.test.ts": 54, - "src/main/providers/pty-process-list-admission.test.ts": 108, - "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1943, - "src/main/providers/settled-pty-writer-census.test.ts": 646, - "src/main/providers/ssh-filesystem-dispatch.test.ts": 41, - "src/main/providers/ssh-filesystem-doc-preview.test.ts": 61, - "src/main/providers/ssh-filesystem-download.test.ts": 183, - "src/main/providers/ssh-filesystem-provider-capabilities.test.ts": 74, - "src/main/providers/ssh-filesystem-provider-download-folder.test.ts": 79, - "src/main/providers/ssh-filesystem-provider-range.test.ts": 84, - "src/main/providers/ssh-filesystem-provider-stream.test.ts": 164, - "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts": 617, - "src/main/providers/ssh-filesystem-provider.test.ts": 323, - "src/main/providers/ssh-filesystem-watch-notifications.test.ts": 53, - "src/main/providers/ssh-git-dispatch.test.ts": 55, - "src/main/providers/ssh-git-provider-api.test.ts": 54, - "src/main/providers/ssh-git-provider-commit-message.test.ts": 68, - "src/main/providers/ssh-git-provider-diff.test.ts": 141, - "src/main/providers/ssh-git-provider-exec.test.ts": 104, - "src/main/providers/ssh-git-provider-merge.test.ts": 95, - "src/main/providers/ssh-git-provider-remote-sync.test.ts": 99, - "src/main/providers/ssh-git-provider-staging.test.ts": 112, - "src/main/providers/ssh-git-provider-status-lease.test.ts": 79, - "src/main/providers/ssh-git-provider-status.test.ts": 106, - "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 78, - "src/main/providers/ssh-git-provider-worktree.test.ts": 140, - "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 103, - "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 54, - "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 207, - "src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 196, - "src/main/providers/ssh-pty-notification-routing.test.ts": 92, - "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 123, - "src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 109, - "src/main/providers/ssh-pty-provider-exit-race.test.ts": 200, - "src/main/providers/ssh-pty-provider-process-events.test.ts": 157, - "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 138, - "src/main/providers/ssh-pty-provider-spawn.test.ts": 124, - "src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 213, - "src/main/providers/ssh-pty-provider.test.ts": 232, - "src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 69, - "src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 157, - "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 49, - "src/main/providers/ssh-pty-write.test.ts": 182, - "src/main/providers/ssh-worktree-catalog-authority.test.ts": 429, + "src/main/opencode/opencode-startup-prompt-claims.test.ts": 59, + "src/main/opencode/opencode-startup-prompt-installer.test.ts": 181, + "src/main/opencode/opencode-startup-prompt-owner.test.ts": 81, + "src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts": 4479, + "src/main/opencode/opencode-startup-prompt-source.test.ts": 4927, + "src/main/opencode/opencode-status-plugin-startup-refresh.test.ts": 52, + "src/main/opencode/opencode-structured-account-home.test.ts": 198, + "src/main/opencode/opencode-wsl-probe-environment.test.ts": 148, + "src/main/opencode/overlay-dir-gc-lifecycle.test.ts": 48, + "src/main/opencode/overlay-dir-gc.test.ts": 131, + "src/main/opencode/overlay-source-ownership.test.ts": 64, + "src/main/opencode/status-plugin-shared-server-capability.test.ts": 43, + "src/main/opencode2/status-plugin-setup-lifetime.test.ts": 98, + "src/main/orca-profiles/other-profile-workspace-catalog.test.ts": 385, + "src/main/orca-profiles/profile-active-transfer-worker.test.ts": 2466, + "src/main/orca-profiles/profile-active-transfer.test.ts": 1924, + "src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts": 640, + "src/main/orca-profiles/profile-cloud-auth-config.test.ts": 65, + "src/main/orca-profiles/profile-cloud-auth-status.test.ts": 72, + "src/main/orca-profiles/profile-cloud-client.test.ts": 148, + "src/main/orca-profiles/profile-cloud-dev-service.test.ts": 511, + "src/main/orca-profiles/profile-cloud-org-members-client.test.ts": 75, + "src/main/orca-profiles/profile-cloud-org-members-service.test.ts": 478, + "src/main/orca-profiles/profile-cloud-pkce.test.ts": 615, + "src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts": 91, + "src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts": 535, + "src/main/orca-profiles/profile-cloud-service-connect-overlap.test.ts": 688, + "src/main/orca-profiles/profile-cloud-service-refresh.test.ts": 548, + "src/main/orca-profiles/profile-cloud-service-sign-out-connect-race.test.ts": 529, + "src/main/orca-profiles/profile-cloud-service.test.ts": 541, + "src/main/orca-profiles/profile-cloud-session-mutation.test.ts": 96, + "src/main/orca-profiles/profile-cloud-session-refresh.test.ts": 86, + "src/main/orca-profiles/profile-cloud-session-store.test.ts": 176, + "src/main/orca-profiles/profile-index-store.test.ts": 1403, + "src/main/orca-profiles/profile-persistence-deadline.test.ts": 61, + "src/main/orca-profiles/profile-project-domain-transfer.test.ts": 2843, + "src/main/orca-profiles/profile-project-presence.test.ts": 680, + "src/main/orca-profiles/profile-project-session-field-disposition.test.ts": 367, + "src/main/orca-profiles/profile-project-session-state.test.ts": 326, + "src/main/orca-profiles/profile-project-state-file.test.ts": 384, + "src/main/orca-profiles/profile-project-transfer-migration.test.ts": 1054, + "src/main/orca-profiles/profile-project-transfer.test.ts": 1132, + "src/main/orca-profiles/profile-session-owner-transfer.test.ts": 268, + "src/main/orca-profiles/profile-telemetry-consent-seed.test.ts": 537, + "src/main/orca-profiles/profile-ui-scope.test.ts": 57, + "src/main/orcad-migration-export-holds.test.ts": 286, + "src/main/orcad/electron-serve-browser-process.test.ts": 2791, + "src/main/orcad/electron-serve-provider-selection.test.ts": 808, + "src/main/orcad/electron-sidecar-method-routing.test.ts": 304, + "src/main/orcad/electron-sidecar-tab-registry.test.ts": 207, + "src/main/orcad/external-chromium-browser-process.integration.test.ts": 429, + "src/main/orcad/external-chromium-browser-process.test.ts": 235, + "src/main/orcad/external-chromium-browser-session.test.ts": 239, + "src/main/orcad/main-preflight-order.test.ts": 808, + "src/main/orcad/native-host-abi.test.ts": 50, + "src/main/orcad/node-pty-prebuilt-slot.test.ts": 302, + "src/main/orcad/node-pty-precondition.test.ts": 614, + "src/main/orcad/orcad-app-paths.test.ts": 47, + "src/main/orcad/orcad-artifact-cache-retention.test.ts": 300, + "src/main/orcad/orcad-artifact-preflight.test.ts": 458, + "src/main/orcad/orcad-automations.test.ts": 2225, + "src/main/orcad/orcad-bind-address.test.ts": 41, + "src/main/orcad/orcad-browser-provider.test.ts": 420, + "src/main/orcad/orcad-browser-startup-cancellation.test.ts": 65, + "src/main/orcad/orcad-browser-startup.test.ts": 61, + "src/main/orcad/orcad-bundle-native-load-order.test.ts": 2425, + "src/main/orcad/orcad-bundled-runtime.integration.test.ts": 4817, + "src/main/orcad/orcad-bundled-runtime.test.ts": 97, + "src/main/orcad/orcad-cli-launcher.test.ts": 116, + "src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts": 397, + "src/main/orcad/orcad-daemon-retirement.test.ts": 186, + "src/main/orcad/orcad-daemon-supervision.test.ts": 63, + "src/main/orcad/orcad-entry.test.ts": 583, + "src/main/orcad/orcad-forked-child-paths.test.ts": 1742, + "src/main/orcad/orcad-health.test.ts": 269, + "src/main/orcad/orcad-help.integration.test.ts": 4302, + "src/main/orcad/orcad-idle-exit-monitor.test.ts": 66, + "src/main/orcad/orcad-idle-stop-record.test.ts": 265, + "src/main/orcad/orcad-instance-lock.test.ts": 302, + "src/main/orcad/orcad-launch-contract.test.ts": 1502, + "src/main/orcad/orcad-launcher-isolation.integration.test.ts": 728, + "src/main/orcad/orcad-lifecycle-host.test.ts": 313, + "src/main/orcad/orcad-local-serve-selection.test.ts": 651, + "src/main/orcad/orcad-managed-idle-exit.test.ts": 230, + "src/main/orcad/orcad-managed-stop-cancel-race.test.ts": 255, + "src/main/orcad/orcad-managed-stop-cancellation.test.ts": 246, + "src/main/orcad/orcad-managed-stop.test.ts": 1323, + "src/main/orcad/orcad-native-preflight.test.ts": 259, + "src/main/orcad/orcad-node-launcher.integration.test.ts": 67, + "src/main/orcad/orcad-observability.test.ts": 280, + "src/main/orcad/orcad-packaged-node-pty.integration.test.ts": 80, + "src/main/orcad/orcad-process-start-time.test.ts": 62, + "src/main/orcad/orcad-profile-preflight.test.ts": 376, + "src/main/orcad/orcad-profile-state-startup.test.ts": 97, + "src/main/orcad/orcad-profile-state-telemetry.test.ts": 49, + "src/main/orcad/orcad-push-startup.test.ts": 2094, + "src/main/orcad/orcad-runtime-entry-artifacts.test.ts": 2239, + "src/main/orcad/orcad-runtime-lifetime.test.ts": 58, + "src/main/orcad/orcad-runtime-native-preflight.test.ts": 108, + "src/main/orcad/orcad-serve-parity.integration.test.ts": 355, + "src/main/orcad/orcad-sidecar-runtime-client.test.ts": 227, + "src/main/orcad/orcad-stop-request-listener.test.ts": 755, + "src/main/orcad/orcad-stop-request-shutdown.integration.test.ts": 482, + "src/main/orcad/orcad-terminal-census.test.ts": 2253, + "src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts": 74, + "src/main/orcad/serve-profile-ssh-targets.test.ts": 476, + "src/main/own-chromium-tree-kill-guard.test.ts": 158, + "src/main/persistence-async-write-syscalls.test.ts": 3814, + "src/main/persistence-automation-extra-agent-args.test.ts": 1200, + "src/main/persistence-automations.test.ts": 2450, + "src/main/persistence-clipboard-selection-migration.test.ts": 1767, + "src/main/persistence-cohort-and-identity-migration.test.ts": 1422, + "src/main/persistence-cross-host-pane-identity.test.ts": 1564, + "src/main/persistence-deregistered-repo-residue.test.ts": 1665, + "src/main/persistence-duplicate-repo-id-host-scope.test.ts": 1664, + "src/main/persistence-feature-interaction-broadcast.benchmark.test.ts": 1812, + "src/main/persistence-floating-terminal-trust.test.ts": 1766, + "src/main/persistence-flush-and-save-scheduling.test.ts": 2502, + "src/main/persistence-folder-scan-project-groups.test.ts": 1419, + "src/main/persistence-folder-workspace-notes.test.ts": 1820, + "src/main/persistence-host-admitted-terminal-membership.test.ts": 1706, + "src/main/persistence-host-partitioned-sessions.test.ts": 1731, + "src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts": 1409, + "src/main/persistence-initial-load.test.ts": 1921, + "src/main/persistence-layout-binding-recovery.test.ts": 1504, + "src/main/persistence-load-repair-durability.test.ts": 1479, + "src/main/persistence-loading-store-extraction.test.ts": 1733, + "src/main/persistence-loading-store-write-risks.test.ts": 1592, + "src/main/persistence-native-chat-appearance.test.ts": 1702, + "src/main/persistence-native-chat-tab-view-mode.test.ts": 1389, + "src/main/persistence-native-chat-upgrade-tip-audience.test.ts": 1987, + "src/main/persistence-orcad-migration-catalog.test.ts": 1607, + "src/main/persistence-orcad-repository-cache-reimport.test.ts": 1240, + "src/main/persistence-pane-alias-selective-saves.test.ts": 1406, + "src/main/persistence-pane-identity-migration.test.ts": 10029, + "src/main/persistence-protected-secret-fail-closed.test.ts": 1679, + "src/main/persistence-protected-secret-write-race.test.ts": 1450, + "src/main/persistence-proxy-secret-recovery.test.ts": 1456, + "src/main/persistence-pty-binding-leaf-tab-resolution.test.ts": 1655, + "src/main/persistence-pty-binding-reconciliation.test.ts": 1737, + "src/main/persistence-remote-session-startup.test.ts": 1716, + "src/main/persistence-repo-lifecycle.test.ts": 2658, + "src/main/persistence-right-sidebar-tab.test.ts": 1623, + "src/main/persistence-settings-ui-defaults.test.ts": 2488, + "src/main/persistence-settings-update.test.ts": 1778, + "src/main/persistence-single-serialize.test.ts": 1683, + "src/main/persistence-source-control-ai-migration.test.ts": 1814, + "src/main/persistence-split-pane-incarnation.test.ts": 1439, + "src/main/persistence-ssh-lease-reattach-reclaim.test.ts": 1505, + "src/main/persistence-ssh-lease-tombstone-retention.test.ts": 1687, + "src/main/persistence-ssh-pending-pty-kill.test.ts": 2151, + "src/main/persistence-ssh-readoption-automation-migration.test.ts": 1910, + "src/main/persistence-ssh-remote-pty-binding-replay.test.ts": 1287, + "src/main/persistence-ssh-remote-pty-leases.test.ts": 1649, + "src/main/persistence-ssh-targets-and-pane-keys.test.ts": 1785, + "src/main/persistence-status-bar-compact-change-notice.test.ts": 1422, + "src/main/persistence-terminal-option-key-migration.test.ts": 1484, + "src/main/persistence-ui-state.test.ts": 2968, + "src/main/persistence-update-repo.test.ts": 1699, + "src/main/persistence-workspace-attachments.test.ts": 1648, + "src/main/persistence-workspace-pinned-automation-fence.test.ts": 1841, + "src/main/persistence-workspace-repin-automation-owner.test.ts": 2087, + "src/main/persistence-workspace-session-scrollback.test.ts": 1556, + "src/main/persistence-workspace-status-workflow.test.ts": 1917, + "src/main/persistence-worktree-card-properties.test.ts": 1715, + "src/main/persistence-worktree-deletion-fencing.test.ts": 1851, + "src/main/persistence-worktree-lineage-and-backups.test.ts": 1621, + "src/main/persistence-worktree-meta-and-folder-workspaces.test.ts": 1769, + "src/main/persistence-worktree-name-retirement.test.ts": 2258, + "src/main/persistence-worktree-visibility.test.ts": 1878, + "src/main/persistence/applying-settings/notification-settings-normalization.test.ts": 48, + "src/main/persistence/applying-settings/settings-update-machine-name.test.ts": 161, + "src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts": 174, + "src/main/persistence/applying-settings/terminal-settings-migrations.test.ts": 74, + "src/main/persistence/atomic-snapshot-writer.test.ts": 76, + "src/main/persistence/closed-terminal-tab-write-fence.test.ts": 5551, + "src/main/persistence/leasing-ssh-ptys/ssh-normalization-orcad-fence.test.ts": 200, + "src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.test.ts": 373, + "src/main/persistence/loading-store/durable-settings-write.test.ts": 1968, + "src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts": 62, + "src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts": 516, + "src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts": 289, + "src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts": 369, + "src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts": 370, + "src/main/persistence/loading-store/persisted-state-redundancy.test.ts": 2095, + "src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts": 1569, + "src/main/persistence/loading-store/profile-state-checkpoints.test.ts": 1426, + "src/main/persistence/loading-store/profile-state-direct-flush.test.ts": 1898, + "src/main/persistence/loading-store/profile-state-import-lifetime.test.ts": 1339, + "src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts": 1841, + "src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts": 2130, + "src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts": 2887, + "src/main/persistence/loading-store/profile-state-maintenance.test.ts": 3136, + "src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts": 3327, + "src/main/persistence/loading-store/profile-state-selective-serialization.test.ts": 361, + "src/main/persistence/loading-store/profile-state-settings-writes.test.ts": 1962, + "src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts": 3947, + "src/main/persistence/loading-store/profile-state-store-backups.test.ts": 1838, + "src/main/persistence/loading-store/profile-state-temp-cleanup-lifetime.test.ts": 1843, + "src/main/persistence/loading-store/profile-state-update-quit.test.ts": 2031, + "src/main/persistence/loading-store/profile-state-worker-coordination.test.ts": 1931, + "src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts": 1546, + "src/main/persistence/loading-store/profile-state-write-batching.test.ts": 1637, + "src/main/persistence/loading-store/pty-binding-async-durability.test.ts": 2161, + "src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts": 1817, + "src/main/persistence/loading-store/pty-binding-fast-lane.test.ts": 406, + "src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts": 1843, + "src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts": 1599, + "src/main/persistence/loading-store/pty-binding-span.test.ts": 118, + "src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts": 5933, + "src/main/persistence/loading-store/pty-retirement-async-durability.test.ts": 2298, + "src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts": 5281, + "src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts": 5496, + "src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts": 5526, + "src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts": 5757, + "src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts": 7219, + "src/main/persistence/loading-store/secret-sentinel-substitution.test.ts": 99, + "src/main/persistence/loading-store/ssh-lease-async-durability.test.ts": 1576, + "src/main/persistence/loading-store/state-write-round-trip.test.ts": 1937, + "src/main/persistence/loading-store/store-prune-gate-signals.test.ts": 1724, + "src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts": 1738, + "src/main/persistence/loading-store/store-workspace-session-host-removal.test.ts": 1405, + "src/main/persistence/loading-store/terminal-close-async-durability.test.ts": 5695, + "src/main/persistence/loading-store/terminal-tab-pty-ownership.test.ts": 68, + "src/main/persistence/loading-store/workspace-session-partitions.test.ts": 435, + "src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts": 73, + "src/main/persistence/loading-store/workspace-session-write-listeners.test.ts": 1588, + "src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts": 2168, + "src/main/persistence/loading-store/worktree-meta-write-normalization.test.ts": 98, + "src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.test.ts": 365, + "src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.test.ts": 405, + "src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.test.ts": 395, + "src/main/persistence/migrating-orcad-catalog/orcad-dormant-metadata-defaults.test.ts": 358, + "src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts": 412, + "src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-census.test.ts": 603, + "src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts": 472, + "src/main/persistence/migrating-orcad-catalog/orcad-source-client-subtraction.test.ts": 390, + "src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts": 475, + "src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts": 1403, + "src/main/persistence/migrating-orcad-catalog/orcad-source-host-qualified-scope.test.ts": 581, + "src/main/persistence/migrating-orcad-catalog/orcad-source-legacy-key-scope.test.ts": 660, + "src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.test.ts": 404, + "src/main/persistence/migrating-orcad-catalog/orcad-source-partial-partition.test.ts": 486, + "src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts": 71, + "src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts": 222, + "src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.test.ts": 440, + "src/main/persistence/profile-state-cutover-fixture.test.ts": 1623, + "src/main/persistence/profile-state-cutover-soak.test.ts": 2103, + "src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.test.ts": 134, + "src/main/persistence/profile-state/profile-state-access-identity.test.ts": 166, + "src/main/persistence/profile-state/profile-state-access-process.test.ts": 592, + "src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts": 102, + "src/main/persistence/profile-state/profile-state-access-windows.test.ts": 195, + "src/main/persistence/profile-state/profile-state-access.test.ts": 277, + "src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts": 1809, + "src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts": 225, + "src/main/persistence/profile-state/profile-state-authority-marker-writes.test.ts": 541, + "src/main/persistence/profile-state/profile-state-authority-marker.test.ts": 1460, + "src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts": 203, + "src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts": 416, + "src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts": 2903, + "src/main/persistence/profile-state/profile-state-backup-path.test.ts": 68, + "src/main/persistence/profile-state/profile-state-backup-rotation.test.ts": 730, + "src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts": 94, + "src/main/persistence/profile-state/profile-state-backup-worker.test.ts": 1602, + "src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts": 1679, + "src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts": 379, + "src/main/persistence/profile-state/profile-state-crash-recovery.test.ts": 596, + "src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts": 316, + "src/main/persistence/profile-state/profile-state-current-json-command.test.ts": 2938, + "src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts": 1748, + "src/main/persistence/profile-state/profile-state-database-export-crash.test.ts": 203, + "src/main/persistence/profile-state/profile-state-database-recovery.test.ts": 1415, + "src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts": 1597, + "src/main/persistence/profile-state/profile-state-database-snapshot.test.ts": 454, + "src/main/persistence/profile-state/profile-state-database.test.ts": 243, + "src/main/persistence/profile-state/profile-state-documents.test.ts": 792, + "src/main/persistence/profile-state/profile-state-domain-equality.test.ts": 282, + "src/main/persistence/profile-state/profile-state-domain-reader.test.ts": 178, + "src/main/persistence/profile-state/profile-state-domain-writes.test.ts": 562, + "src/main/persistence/profile-state/profile-state-fragment-validation.test.ts": 1697, + "src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts": 1489, + "src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts": 3444, + "src/main/persistence/profile-state/profile-state-live-store-factory.test.ts": 2219, + "src/main/persistence/profile-state/profile-state-offline-settings.test.ts": 422, + "src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts": 229, + "src/main/persistence/profile-state/profile-state-read-concurrency.test.ts": 200, + "src/main/persistence/profile-state/profile-state-recovery-batch.test.ts": 164, + "src/main/persistence/profile-state/profile-state-recovery-copy.test.ts": 111, + "src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts": 11352, + "src/main/persistence/profile-state/profile-state-recovery-crash-process.test.ts": 228, + "src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts": 602, + "src/main/persistence/profile-state/profile-state-selected-profile-recovery.test.ts": 1692, + "src/main/persistence/profile-state/profile-state-startup-authority.test.ts": 2001, + "src/main/persistence/profile-state/profile-state-startup-failure.test.ts": 1463, + "src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts": 266, + "src/main/persistence/profile-state/profile-state-startup-secrets.test.ts": 1720, + "src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts": 1603, + "src/main/persistence/profile-state/profile-state-store-factory.test.ts": 2673, + "src/main/persistence/profile-state/profile-state-streaming-validation.test.ts": 282, + "src/main/persistence/profile-state/profile-state-worker-authority.test.ts": 1651, + "src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts": 440, + "src/main/persistence/profile-state/profile-state-write-transaction.test.ts": 74, + "src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts": 66, + "src/main/persistence/profile-state/profile-state-writer-close.test.ts": 86, + "src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts": 936, + "src/main/persistence/profile-state/profile-state-writer-sleep.test.ts": 782, + "src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts": 8846, + "src/main/persistence/profile-state/profile-state-writer-worker.test.ts": 1542, + "src/main/persistence/restoring-sessions/pane-alias-normalization-scaling.test.ts": 85, + "src/main/persistence/restoring-sessions/pane-alias-normalization.test.ts": 921, + "src/main/persistence/restoring-sessions/pane-key-remapping.test.ts": 50, + "src/main/persistence/restoring-sessions/session-owner-fields.test.ts": 333, + "src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts": 348, + "src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts": 60, + "src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts": 101, + "src/main/persistence/runtime-authored-workspace-session-fields.test.ts": 49, + "src/main/persistence/scheduling-automations/automation-run-operations.test.ts": 143, + "src/main/persistence/terminal-topology/terminal-late-bind-layout-rules.test.ts": 1562, + "src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts": 1529, + "src/main/persistence/terminal-topology/terminal-leaf-move.test.ts": 1619, + "src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts": 299, + "src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts": 1399, + "src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts": 1626, + "src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts": 4000, + "src/main/persistence/terminal-topology/terminal-topology-commit.test.ts": 280, + "src/main/persistence/terminal-topology/terminal-topology-model.test.ts": 2243, + "src/main/persistence/terminal-topology/workspace-layout-rules.test.ts": 304, + "src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts": 409, + "src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts": 418, + "src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts": 503, + "src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts": 400, + "src/main/persistence/tracking-repos/project-group-session-batch.test.ts": 353, + "src/main/persistence/tracking-repos/project-host-compatibility.test.ts": 61, + "src/main/persistence/tracking-repos/repo-update-host-guard.test.ts": 424, + "src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts": 351, + "src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts": 327, + "src/main/pi/agent-status-completion-delivery.test.ts": 1338, + "src/main/pi/agent-status-extension-async-subagents.test.ts": 1604, + "src/main/pi/agent-status-extension-nested-run-settlement.test.ts": 486, + "src/main/pi/agent-status-extension-omp-lifecycle.test.ts": 2614, + "src/main/pi/agent-status-extension-omp-model.test.ts": 1167, + "src/main/pi/agent-status-extension-omp-prefill.test.ts": 698, + "src/main/pi/agent-status-extension-session-change.test.ts": 2226, + "src/main/pi/agent-status-extension-source.test.ts": 2691, + "src/main/pi/agent-status-input-redaction.test.ts": 1660, + "src/main/pi/agent-status-omp-approval-forwarding.test.ts": 914, + "src/main/pi/agent-status-owner-recovery.test.ts": 1128, + "src/main/pi/agent-status-reload-delivery.test.ts": 680, + "src/main/pi/agent-status-ui-prompt.test.ts": 1662, + "src/main/pi/omp-model-command-source.test.ts": 789, + "src/main/pi/omp-session-status-owner.test.ts": 1010, + "src/main/pi/omp-transcript-metadata.test.ts": 1375, + "src/main/pi/rpc-capture-replay.test.ts": 518, + "src/main/pi/rpc-configured-default.test.ts": 1225, + "src/main/pi/rpc-launch-resolution.test.ts": 810, + "src/main/pi/rpc-launch.test.ts": 432, + "src/main/pi/rpc-location.test.ts": 1032, + "src/main/pi/rpc-message-dialog-shapes.test.ts": 205, + "src/main/pi/rpc-model-catalog-probe.test.ts": 481, + "src/main/pi/rpc-options.test.ts": 208, + "src/main/pi/rpc-prompt-delivery.test.ts": 300, + "src/main/pi/rpc-prompt.test.ts": 253, + "src/main/pi/rpc-runtime-registration.test.ts": 452, + "src/main/pi/rpc-session-adapter.test.ts": 1081, + "src/main/pi/rpc-turn-races.test.ts": 714, + "src/main/pi/titlebar-extension-overlay-path.test.ts": 83, + "src/main/pi/titlebar-extension-service.test.ts": 230, + "src/main/pi/titlebar-extension-source.test.ts": 1009, + "src/main/plugins/plugin-agent-status-event.test.ts": 227, + "src/main/plugins/plugin-atomic-file-write.test.ts": 2063, + "src/main/plugins/plugin-audit-log-scaling.test.ts": 405, + "src/main/plugins/plugin-audit-log.test.ts": 66, + "src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts": 425, + "src/main/plugins/plugin-bundled-bootstrap.test.ts": 486, + "src/main/plugins/plugin-command-registry.test.ts": 345, + "src/main/plugins/plugin-content-pack-registry.test.ts": 302, + "src/main/plugins/plugin-content-safety.test.ts": 850, + "src/main/plugins/plugin-dev-watcher-unknown-identity.test.ts": 252, + "src/main/plugins/plugin-dev-watcher.test.ts": 509, + "src/main/plugins/plugin-discovery.test.ts": 325, + "src/main/plugins/plugin-enablement.test.ts": 366, + "src/main/plugins/plugin-host-methods.test.ts": 300, + "src/main/plugins/plugin-host-process.test.ts": 249, + "src/main/plugins/plugin-host-runtime.test.ts": 258, + "src/main/plugins/plugin-install-trust.test.ts": 464, + "src/main/plugins/plugin-install.test.ts": 819, + "src/main/plugins/plugin-kill-list-content-revocation.test.ts": 541, + "src/main/plugins/plugin-kill-list-service.test.ts": 292, + "src/main/plugins/plugin-language-pack-registry.test.ts": 352, + "src/main/plugins/plugin-launch-content.test.ts": 464, + "src/main/plugins/plugin-list-projection.test.ts": 320, + "src/main/plugins/plugin-marketplace-installer.test.ts": 425, + "src/main/plugins/plugin-marketplace-service.test.ts": 526, + "src/main/plugins/plugin-marketplace-store.test.ts": 269, + "src/main/plugins/plugin-panel-controller.test.ts": 337, + "src/main/plugins/plugin-panel-owner-lifecycle.test.ts": 51, + "src/main/plugins/plugin-panel-sessions.test.ts": 56, + "src/main/plugins/plugin-private-marketplace.integration.test.ts": 715, + "src/main/plugins/plugin-secrets-store.test.ts": 295, + "src/main/plugins/plugin-service-housekeeping.test.ts": 63, + "src/main/plugins/plugin-service-integrity.test.ts": 555, + "src/main/plugins/plugin-service-reconciliation.test.ts": 841, + "src/main/plugins/plugin-startup-budget.test.ts": 757, + "src/main/plugins/plugin-storage-store.test.ts": 286, + "src/main/plugins/plugin-uninstall-lifecycle.test.ts": 994, + "src/main/plugins/plugin-uninstall-log-retention.test.ts": 1223, + "src/main/plugins/plugin-uninstall-ordering.test.ts": 892, + "src/main/plugins/plugin-vm-recipe-registry.test.ts": 426, + "src/main/plugins/plugin-worker-controller.test.ts": 371, + "src/main/plugins/plugin-worker-env.test.ts": 48, + "src/main/plugins/plugin-worker-manager.test.ts": 311, + "src/main/plugins/plugin-worker-output-buffer.test.ts": 49, + "src/main/plugins/plugin-worker-output-retention.test.ts": 379, + "src/main/plugins/plugin-worker-supervision.integration.test.ts": 8486, + "src/main/ports/advertised-url-retention.test.ts": 145, + "src/main/ports/advertised-url-watcher-pid-validation.test.ts": 66, + "src/main/ports/advertised-url-watcher.test.ts": 91, + "src/main/ports/local-workspace-port-scanner.test.ts": 144, + "src/main/ports/port-scan-command-client.test.ts": 1724, + "src/main/ports/port-scan-command-execution.test.ts": 5262, + "src/main/ports/ssh-advertised-url-enrichment.test.ts": 83, + "src/main/ports/workspace-port-execution-host.test.ts": 59, + "src/main/ports/workspace-port-ownership.test.ts": 62, + "src/main/powershell-osc133-bootstrap-windows-clm.test.ts": 109, + "src/main/powershell-osc133-bootstrap.test.ts": 159, + "src/main/preflight/freebuff-remote-detection.test.ts": 620, + "src/main/preflight/workspace-agent-detection.test.ts": 575, + "src/main/project-groups/folder-workspace-path-status.test.ts": 73, + "src/main/project-groups/nested-repo-discovery-queue.test.ts": 208, + "src/main/project-groups/nested-repo-discovery.test.ts": 271, + "src/main/project-groups/nested-repo-glob-budget.test.ts": 391, + "src/main/project-groups/nested-repo-import-target.test.ts": 56, + "src/main/project-groups/nested-repo-import.test.ts": 63, + "src/main/project-groups/nested-repo-scan-rules.test.ts": 52, + "src/main/project-runtime-git-options.test.ts": 118, + "src/main/protected-secret-persistence-concurrency.test.ts": 61, + "src/main/protected-secret-persistence.test.ts": 77, + "src/main/provider-process/managed-provider-process-fallback-tree.test.ts": 98, + "src/main/provider-process/managed-provider-process-grace.test.ts": 108, + "src/main/provider-process/managed-provider-process-output.test.ts": 101, + "src/main/provider-process/managed-provider-process-root-only.test.ts": 105, + "src/main/provider-process/managed-provider-process.test.ts": 121, + "src/main/provider-process/provider-acquisition-starts.test.ts": 46, + "src/main/provider-process/provider-process-launch.test.ts": 46, + "src/main/provider-process/provider-process-supervisor.integration.test.ts": 14407, + "src/main/provider-process/provider-process-supervisor.test.ts": 65, + "src/main/provider-process/provider-process-teardown.test.ts": 140, + "src/main/provider-process/provider-spawn-failure-report.test.ts": 51, + "src/main/provider-process/provider-stdio-write-queue.test.ts": 48, + "src/main/providers/agent-foreground-command-line.test.ts": 157, + "src/main/providers/agent-foreground-process-batch.test.ts": 89, + "src/main/providers/agent-foreground-process-git-bash-launcher.test.ts": 137, + "src/main/providers/agent-foreground-process-git-bash.win32.test.ts": 445, + "src/main/providers/agent-foreground-process-pi.test.ts": 136, + "src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 155, + "src/main/providers/agent-foreground-process-real-rows.test.ts": 141, + "src/main/providers/agent-foreground-process-remote-evidence.test.ts": 108, + "src/main/providers/agent-foreground-process-shell-path.test.ts": 131, + "src/main/providers/agent-foreground-process-windows-job-rejection.test.ts": 107, + "src/main/providers/agent-foreground-process-wsl-shell-proof.test.ts": 125, + "src/main/providers/agent-foreground-process.test.ts": 186, + "src/main/providers/execution-host-provider-dispatch.test.ts": 404, + "src/main/providers/local-filesystem-provider.test.ts": 206, + "src/main/providers/local-git-provider.test.ts": 77, + "src/main/providers/local-pty-child-process-verdict.test.ts": 498, + "src/main/providers/local-pty-default-shell.test.ts": 168, + "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 202, + "src/main/providers/local-pty-pending-native-spawn.test.ts": 306, + "src/main/providers/local-pty-pi-owner-env.test.ts": 257, + "src/main/providers/local-pty-provider-foreground-process.test.ts": 645, + "src/main/providers/local-pty-provider-io-events.test.ts": 438, + "src/main/providers/local-pty-provider-session-inventory.test.ts": 475, + "src/main/providers/local-pty-provider-shell-readiness.test.ts": 521, + "src/main/providers/local-pty-provider-shutdown.test.ts": 714, + "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 501, + "src/main/providers/local-pty-provider-spawn-env.test.ts": 493, + "src/main/providers/local-pty-provider-spawn-session.test.ts": 539, + "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 571, + "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 82, + "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 74, + "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 491, + "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 310, + "src/main/providers/local-pty-utils-windows-fallback.test.ts": 79, + "src/main/providers/local-pty-utils.test.ts": 91, + "src/main/providers/macos-login-session-pty-probe.test.ts": 54, + "src/main/providers/macos-tcc-login-shell.test.ts": 148, + "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 62, + "src/main/providers/process-cwd.test.ts": 136, + "src/main/providers/provider-dispatch.test.ts": 2275, + "src/main/providers/pty-default-cwd.test.ts": 64, + "src/main/providers/pty-process-inspection.test.ts": 55, + "src/main/providers/pty-process-list-admission.test.ts": 150, + "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1987, + "src/main/providers/ssh-filesystem-dispatch.test.ts": 52, + "src/main/providers/ssh-git-dispatch.test.ts": 54, + "src/main/providers/ssh-git-provider-commit-message.test.ts": 122, + "src/main/providers/ssh-git-provider-diff.test.ts": 140, + "src/main/providers/ssh-git-provider-exec.test.ts": 126, + "src/main/providers/ssh-git-provider-merge.test.ts": 103, + "src/main/providers/ssh-git-provider-remote-sync.test.ts": 115, + "src/main/providers/ssh-git-provider-staging.test.ts": 163, + "src/main/providers/ssh-git-provider-status-lease.test.ts": 102, + "src/main/providers/ssh-git-provider-status.test.ts": 112, + "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 110, + "src/main/providers/ssh-git-provider-worktree.test.ts": 149, + "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 125, + "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 59, + "src/main/providers/ssh-pty-legacy-relay-delegation.test.ts": 450, + "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 434, + "src/main/providers/ssh-pty-notification-recovery-activation.test.ts": 66, + "src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 210, + "src/main/providers/ssh-pty-notification-routing.test.ts": 90, + "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 446, + "src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 435, + "src/main/providers/ssh-pty-provider-exit-race.test.ts": 509, + "src/main/providers/ssh-pty-provider-process-events.test.ts": 474, + "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 529, + "src/main/providers/ssh-pty-provider-spawn.test.ts": 499, + "src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 455, + "src/main/providers/ssh-pty-provider.test.ts": 470, + "src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 249, + "src/main/providers/ssh-pty-reattach-previous-relay-hold.test.ts": 69, + "src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 430, + "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 58, + "src/main/providers/ssh-pty-spawn-env.test.ts": 49, + "src/main/providers/ssh-pty-write.test.ts": 473, + "src/main/providers/ssh-worktree-catalog-authority.test.ts": 1068, "src/main/providers/stable-foreground-process.test.ts": 68, - "src/main/providers/terminal-path-existence-batch.integration.test.ts": 171, - "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 70, - "src/main/providers/windows-cached-agent-revalidation.test.ts": 56, - "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 34, - "src/main/providers/windows-console-attached-processes.test.ts": 69, - "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 88, - "src/main/providers/windows-foreground-process-rows.test.ts": 40, - "src/main/providers/windows-powershell-executable.test.ts": 67, - "src/main/providers/windows-powershell.test.ts": 53, - "src/main/providers/windows-pty-job-membership.test.ts": 95, - "src/main/providers/windows-shell-args.test.ts": 131, - "src/main/providers/windows-shell-fallback-chain.test.ts": 124, + "src/main/providers/startup-line-typed-length.live-shell.test.ts": 165, + "src/main/providers/terminal-path-existence-batch.integration.test.ts": 150, + "src/main/providers/windows-agent-foreground-command-line.test.ts": 102, + "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 107, + "src/main/providers/windows-cached-agent-revalidation.test.ts": 55, + "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 59, + "src/main/providers/windows-console-attached-processes.test.ts": 56, + "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 105, + "src/main/providers/windows-foreground-process-rows.test.ts": 90, + "src/main/providers/windows-powershell-executable.test.ts": 76, + "src/main/providers/windows-powershell.test.ts": 42, + "src/main/providers/windows-pty-job-membership.test.ts": 68, + "src/main/providers/windows-shell-args.test.ts": 192, + "src/main/providers/windows-shell-fallback-chain.test.ts": 131, "src/main/providers/windows-shell-preflight-runtime.windows.test.ts": 123, - "src/main/providers/working-directory-validation-retention.test.ts": 125, - "src/main/providers/working-directory-validation.test.ts": 111, - "src/main/provisioned-root-ssh-adoption.test.ts": 285, - "src/main/proxy-guarded-fetch-call-site-audit.test.ts": 266, - "src/main/pty-descendant-exit-verification.test.ts": 83, - "src/main/pty-descendant-termination-job-coverage.test.ts": 303, - "src/main/pty-descendant-termination.test.ts": 205, - "src/main/pty/appimage-terminal-env.test.ts": 42, - "src/main/pty/build-mode-env.test.ts": 42, - "src/main/pty/codex-home-wsl-env.test.ts": 33, - "src/main/pty/codex-preflight-profile-path-rewrite.test.ts": 168, - "src/main/pty/codex-shell-launch-preflight.test.ts": 2210, - "src/main/pty/conda-activation-env.test.ts": 36, - "src/main/pty/legacy-terminal-shim-dir.test.ts": 444, - "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 59, - "src/main/pty/node-pty-master-fd-retirement.test.ts": 5222, - "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 42, - "src/main/pty/omp-sqlite-overlay.test.ts": 43, - "src/main/pty/overlay-mirror.test.ts": 41, - "src/main/pty/pi-process-owner-env.test.ts": 929, - "src/main/pty/posix-pty-foreground-group.test.ts": 60, - "src/main/pty/posix-pty-process-groups.integration.test.ts": 123, - "src/main/pty/posix-pty-process-groups.test.ts": 78, - "src/main/pty/shell-startup-env.test.ts": 116, - "src/main/pty/terminal-color-env.test.ts": 49, - "src/main/pty/windows-environment-path-main-loop.test.ts": 414, - "src/main/pty/windows-environment-path.test.ts": 72, - "src/main/pty/windows-path-registry-change.test.ts": 37, - "src/main/pty/windows-path-registry-fallback.test.ts": 66, - "src/main/pty/windows-path-registry-reader.test.ts": 61, - "src/main/pty/wsl-orca-env.test.ts": 85, - "src/main/pwsh.test.ts": 74, - "src/main/quit-path-durable-write-blocking.test.ts": 2121, - "src/main/quit-teardown-agent-browser-daemons.test.ts": 27, - "src/main/quit-teardown-deadline.test.ts": 52, - "src/main/quit-teardown-start-gate.test.ts": 45, - "src/main/rate-limits/account-runtime-target-sync.test.ts": 205, - "src/main/rate-limits/antigravity-usage-mirror.test.ts": 38, - "src/main/rate-limits/auth-filesystem-operation-retention.test.ts": 132, - "src/main/rate-limits/auth-filesystem-operation.test.ts": 56, - "src/main/rate-limits/claude-fetcher-cli-fallback.test.ts": 183, - "src/main/rate-limits/claude-fetcher-fable-usage.test.ts": 251, - "src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts": 273, - "src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts": 167, - "src/main/rate-limits/claude-oauth-usage-error.test.ts": 102, - "src/main/rate-limits/claude-pty.test.ts": 178, - "src/main/rate-limits/claude-usage-error-classification.test.ts": 70, - "src/main/rate-limits/claude-usage-refresh-plan.test.ts": 33, - "src/main/rate-limits/codex-auth-presence.test.ts": 45, - "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 152, - "src/main/rate-limits/codex-fetcher-backend.test.ts": 251, - "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 223, - "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 190, - "src/main/rate-limits/codex-fetcher-pty-settle.test.ts": 271, - "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 465, - "src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts": 108, - "src/main/rate-limits/codex-fetcher-session-supplement.test.ts": 186, - "src/main/rate-limits/codex-fetcher.test.ts": 339, - "src/main/rate-limits/codex-probe-termination.test.ts": 63, - "src/main/rate-limits/codex-pty-rate-limit-probe.test.ts": 70, - "src/main/rate-limits/codex-pty-status-parser.test.ts": 47, - "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 26, - "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 109, - "src/main/rate-limits/cursor-auth.test.ts": 133, - "src/main/rate-limits/cursor-fetcher.test.ts": 173, - "src/main/rate-limits/cursor-session-token.test.ts": 71, - "src/main/rate-limits/cursor-usage-mapping.test.ts": 97, - "src/main/rate-limits/gemini-bucket-formatting.test.ts": 37, - "src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 113, - "src/main/rate-limits/gemini-usage-fetcher.test.ts": 100, - "src/main/rate-limits/grok-auth.test.ts": 101, - "src/main/rate-limits/grok-fetcher.test.ts": 152, - "src/main/rate-limits/hidden-pty-cleanup.test.ts": 53, - "src/main/rate-limits/initial-account-rate-limit-target.test.ts": 233, - "src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 92, - "src/main/rate-limits/kimi-fetcher.test.ts": 129, - "src/main/rate-limits/minimax/minimax-fetcher.test.ts": 109, - "src/main/rate-limits/minimax/minimax-request-context.test.ts": 89, - "src/main/rate-limits/opencode-go-api-key-source.test.ts": 73, - "src/main/rate-limits/opencode-go-status-parsing.test.ts": 63, - "src/main/rate-limits/opencode-go-usage-api.test.ts": 109, - "src/main/rate-limits/opencode-go-usage-fetcher.test.ts": 79, - "src/main/rate-limits/opencode-go-usage-source-selection.test.ts": 104, - "src/main/rate-limits/service-account-target-selection.test.ts": 780, - "src/main/rate-limits/service-antigravity-usage.test.ts": 123, - "src/main/rate-limits/service-cursor-usage.test.ts": 229, - "src/main/rate-limits/service-inactive-account-previews.test.ts": 187, - "src/main/rate-limits/service-live-claude-usage.test.ts": 173, - "src/main/rate-limits/service-minimax-usage.test.ts": 91, - "src/main/rate-limits/service-refresh-orchestration.test.ts": 243, - "src/main/rate-limits/service-window-activation.test.ts": 321, - "src/main/refused-tree-kill-root-termination.test.ts": 2628, - "src/main/remote-agent-trust-presets.test.ts": 166, - "src/main/remote-worktree-history-cleanup.test.ts": 59, - "src/main/repo-git-remote-avatar-refresh.test.ts": 108, - "src/main/repo-git-remote-identity-enrichment.test.ts": 91, - "src/main/repo-git-remote-identity.test.ts": 71, - "src/main/repo-git-username-enrichment.test.ts": 43, - "src/main/repo-icon-autodetect.test.ts": 606, - "src/main/repo-icon-file-detection.test.ts": 92, - "src/main/repo-icon-source-href.test.ts": 109, - "src/main/repo-maintenance-idle-gate.test.ts": 53, - "src/main/repo-worktrees.test.ts": 104, - "src/main/ripgrep/bare-ripgrep-spawn-boundary.test.ts": 1021, - "src/main/ripgrep/bundled-ripgrep-path.test.ts": 104, - "src/main/ripgrep/bundled-ripgrep-real.test.ts": 150, - "src/main/ripgrep/bundled-ripgrep-spawn.test.ts": 67, - "src/main/ripgrep/fd-exhausted-spawn.test.ts": 68, - "src/main/ripgrep/synchronous-spawn-failure.test.ts": 63, - "src/main/ripgrep/wsl-missing-cwd-exit.test.ts": 92, - "src/main/runtime/acknowledged-terminal-tab-retirement.test.ts": 3317, - "src/main/runtime/agent-prompt-line-settle.test.ts": 2927, - "src/main/runtime/agent-prompt-receipt-correlation.test.ts": 3413, + "src/main/providers/working-directory-validation-retention.test.ts": 132, + "src/main/providers/working-directory-validation.test.ts": 118, + "src/main/provisioned-root-ssh-adoption.test.ts": 332, + "src/main/pty-descendant-exit-verification.test.ts": 139, + "src/main/pty-descendant-termination.test.ts": 173, + "src/main/pty/appimage-terminal-env.test.ts": 68, + "src/main/pty/build-mode-env.test.ts": 49, + "src/main/pty/chromium-session-bus-env.test.ts": 202, + "src/main/pty/codex-home-wsl-env.test.ts": 44, + "src/main/pty/codex-launch-shell-wrapping.test.ts": 180, + "src/main/pty/codex-no-daemon-binary-contract.test.ts": 44, + "src/main/pty/codex-no-daemon-launch-command.test.ts": 96, + "src/main/pty/codex-preflight-profile-path-rewrite.test.ts": 137, + "src/main/pty/codex-shell-launch-preflight.test.ts": 2497, + "src/main/pty/codex-shell-no-daemon.test.ts": 7782, + "src/main/pty/conda-activation-env.test.ts": 64, + "src/main/pty/legacy-terminal-shim-dir.test.ts": 287, + "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 85, + "src/main/pty/managed-ssh-host-login-shell.test.ts": 179, + "src/main/pty/node-pty-master-fd-retirement.test.ts": 2373, + "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 55, + "src/main/pty/omp-sqlite-overlay.test.ts": 51, + "src/main/pty/overlay-mirror.test.ts": 69, + "src/main/pty/pi-process-owner-env.test.ts": 909, + "src/main/pty/posix-pty-foreground-group.test.ts": 65, + "src/main/pty/posix-pty-process-groups.integration.test.ts": 167, + "src/main/pty/posix-pty-process-groups.test.ts": 105, + "src/main/pty/powershell-profile-env.test.ts": 98, + "src/main/pty/shell-startup-env.test.ts": 131, + "src/main/pty/terminal-color-env.test.ts": 46, + "src/main/pty/windows-environment-path-main-loop.test.ts": 430, + "src/main/pty/windows-environment-path.test.ts": 118, + "src/main/pty/windows-path-registry-change.test.ts": 49, + "src/main/pty/windows-path-registry-fallback.test.ts": 69, + "src/main/pty/windows-path-registry-reader.test.ts": 55, + "src/main/pty/wsl-orca-env.test.ts": 107, + "src/main/pwsh.test.ts": 78, + "src/main/qoder/hook-service.test.ts": 146, + "src/main/qoder/real-cli.test.ts": 709, + "src/main/quit-path-durable-write-blocking.test.ts": 3220, + "src/main/quit-teardown-deadline.test.ts": 54, + "src/main/quit-teardown-start-gate.test.ts": 38, + "src/main/rate-limits/account-runtime-target-sync.test.ts": 277, + "src/main/rate-limits/antigravity-usage-error.test.ts": 52, + "src/main/rate-limits/antigravity-usage-fetcher.test.ts": 103, + "src/main/rate-limits/antigravity-usage-real-cli.test.ts": 124, + "src/main/rate-limits/antigravity-usage-response.test.ts": 66, + "src/main/rate-limits/auth-filesystem-operation-retention.test.ts": 140, + "src/main/rate-limits/auth-filesystem-operation.test.ts": 95, + "src/main/rate-limits/claude-oauth-usage-error.test.ts": 44, + "src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts": 84, + "src/main/rate-limits/claude-profile-usage.test.ts": 275, + "src/main/rate-limits/claude-usage-error-classification.test.ts": 58, + "src/main/rate-limits/codex-auth-presence.test.ts": 63, + "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 120, + "src/main/rate-limits/codex-fetcher-backend.test.ts": 267, + "src/main/rate-limits/codex-fetcher-credit-balance.test.ts": 111, + "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 166, + "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 237, + "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 358, + "src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts": 136, + "src/main/rate-limits/codex-fetcher-session-supplement.test.ts": 152, + "src/main/rate-limits/codex-fetcher.test.ts": 265, + "src/main/rate-limits/codex-probe-termination.test.ts": 81, + "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 41, + "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 156, + "src/main/rate-limits/cursor-auth.test.ts": 220, + "src/main/rate-limits/cursor-fetcher.test.ts": 370, + "src/main/rate-limits/cursor-session-token.test.ts": 214, + "src/main/rate-limits/cursor-usage-mapping.test.ts": 247, + "src/main/rate-limits/gemini-bucket-formatting.test.ts": 60, + "src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 88, + "src/main/rate-limits/gemini-usage-fetcher.test.ts": 112, + "src/main/rate-limits/grok-auth.test.ts": 103, + "src/main/rate-limits/grok-fetcher.test.ts": 97, + "src/main/rate-limits/grok-weekly-zero.test.ts": 180, + "src/main/rate-limits/initial-account-rate-limit-target.test.ts": 295, + "src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 107, + "src/main/rate-limits/kimi-fetcher.test.ts": 175, + "src/main/rate-limits/minimax/minimax-fetcher.test.ts": 122, + "src/main/rate-limits/minimax/minimax-request-context.test.ts": 97, + "src/main/rate-limits/opencode-go-api-key-source.test.ts": 452, + "src/main/rate-limits/opencode-go-managed-account-source.test.ts": 514, + "src/main/rate-limits/opencode-go-status-parsing.test.ts": 56, + "src/main/rate-limits/opencode-go-usage-api.test.ts": 77, + "src/main/rate-limits/opencode-go-usage-fetcher.test.ts": 136, + "src/main/rate-limits/opencode-go-usage-source-selection.test.ts": 107, + "src/main/rate-limits/service-account-target-selection.test.ts": 1032, + "src/main/rate-limits/service-antigravity-usage.test.ts": 139, + "src/main/rate-limits/service-cursor-usage.test.ts": 430, + "src/main/rate-limits/service-inactive-account-previews.test.ts": 158, + "src/main/rate-limits/service-live-claude-usage.test.ts": 188, + "src/main/rate-limits/service-minimax-usage.test.ts": 159, + "src/main/rate-limits/service-opencode-go-credentials.test.ts": 136, + "src/main/rate-limits/service-refresh-orchestration.test.ts": 177, + "src/main/rate-limits/service-window-activation.test.ts": 363, + "src/main/rate-limits/service-zcode-usage.test.ts": 322, + "src/main/rate-limits/zcode-usage-fetcher.test.ts": 77, + "src/main/refused-tree-kill-root-termination.test.ts": 293, + "src/main/remote-worktree-history-cleanup.test.ts": 44, + "src/main/repo-git-remote-avatar-refresh.test.ts": 135, + "src/main/repo-git-remote-identity-enrichment.test.ts": 119, + "src/main/repo-git-remote-identity.test.ts": 402, + "src/main/repo-git-username-enrichment.test.ts": 65, + "src/main/repo-icon-autodetect.test.ts": 826, + "src/main/repo-icon-file-detection.test.ts": 455, + "src/main/repo-icon-source-href.test.ts": 81, + "src/main/repo-maintenance-idle-gate.test.ts": 219, + "src/main/repo-worktrees.test.ts": 372, + "src/main/ripgrep/bundled-ripgrep-path.test.ts": 107, + "src/main/ripgrep/bundled-ripgrep-real.test.ts": 166, + "src/main/ripgrep/bundled-ripgrep-spawn.test.ts": 80, + "src/main/ripgrep/bundled-ripgrep-stop.test.ts": 84, + "src/main/ripgrep/bundled-ripgrep-text-search.test.ts": 207, + "src/main/ripgrep/fd-exhausted-spawn.test.ts": 75, + "src/main/ripgrep/ripgrep-filename-identity-real.test.ts": 209, + "src/main/ripgrep/synchronous-spawn-failure.test.ts": 69, + "src/main/ripgrep/text-search-unicode-real.test.ts": 228, + "src/main/ripgrep/wsl-missing-cwd-exit.test.ts": 124, + "src/main/runtime/acknowledged-terminal-tab-retirement.test.ts": 5402, + "src/main/runtime/agent-detection-host-resolution.test.ts": 3351, + "src/main/runtime/agent-launch-record-warmup-gate.test.ts": 42, + "src/main/runtime/agent-launch-typed-line-shell.test.ts": 50, + "src/main/runtime/agent-prompt-line-settle.test.ts": 4010, + "src/main/runtime/agent-prompt-receipt-correlation.test.ts": 4528, "src/main/runtime/agent-prompt-request-correlation.test.ts": 46, - "src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts": 3872, - "src/main/runtime/agent-prompt-submission-runtime.test.ts": 4515, - "src/main/runtime/agent-prompt-submission-verification.test.ts": 193, - "src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts": 2931, - "src/main/runtime/agent-session-backup-recovery.test.ts": 422, - "src/main/runtime/agent-session-claim-identity.test.ts": 71, - "src/main/runtime/agent-session-conversation-name-store.test.ts": 302, - "src/main/runtime/agent-session-launch-env-admission.test.ts": 253, - "src/main/runtime/agent-session-launch-env-backfill.test.ts": 264, - "src/main/runtime/agent-session-lease-renewal.test.ts": 242, - "src/main/runtime/agent-session-operation-admission.test.ts": 165, - "src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts": 47, - "src/main/runtime/agent-session-process-identity-probe.test.ts": 78, - "src/main/runtime/agent-session-provider-handle-transition.test.ts": 104, - "src/main/runtime/agent-session-record-conversation-name.test.ts": 164, - "src/main/runtime/agent-session-record-options.test.ts": 203, - "src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts": 303, - "src/main/runtime/agent-session-record-store-security.test.ts": 156, - "src/main/runtime/agent-session-record-store-tab-table.test.ts": 420, - "src/main/runtime/agent-session-record-store.test.ts": 4639, - "src/main/runtime/agent-session-record-unsupported-schema.test.ts": 255, - "src/main/runtime/agent-session-recovery-capsule.test.ts": 642, - "src/main/runtime/agent-session-recovery-publish-fault.test.ts": 252, - "src/main/runtime/agent-session-released-reservation-replay.test.ts": 351, - "src/main/runtime/agent-session-reservation-admission.test.ts": 150, - "src/main/runtime/agent-session-spawn-token-process-scan.test.ts": 54, - "src/main/runtime/agent-session-spawn-token-readback.test.ts": 52, - "src/main/runtime/agent-session-surface-release-transition.test.ts": 167, - "src/main/runtime/agent-session-unreadable-record-salvage.test.ts": 310, - "src/main/runtime/agent-session-unsaved-creation-supersession.test.ts": 363, - "src/main/runtime/agent-startup-input-assembly-census.test.ts": 41, - "src/main/runtime/agent-terminal-launch-trust-host.test.ts": 3780, - "src/main/runtime/agent-terminal-startup-prompt.test.ts": 2169, - "src/main/runtime/antigravity-readiness-transcripts.test.ts": 6110, - "src/main/runtime/antigravity-terminal-readiness.test.ts": 78, - "src/main/runtime/automation-change-publication.test.ts": 8897, - "src/main/runtime/browser-client-download-transfers.test.ts": 201, - "src/main/runtime/browser-dialog-settlement.test.ts": 359, - "src/main/runtime/browser-execution-host-key-resolution.test.ts": 1993, - "src/main/runtime/browser-host-capability-selection.test.ts": 172, - "src/main/runtime/browser-host-client-page-adoption-grants.test.ts": 46, - "src/main/runtime/browser-host-client-page-adoption.test.ts": 36, - "src/main/runtime/browser-host-client-page-creation.test.ts": 133, - "src/main/runtime/browser-host-command-ledger-capacity.test.ts": 157, - "src/main/runtime/browser-host-command-ledger.test.ts": 138, - "src/main/runtime/browser-host-file-channel-admission.test.ts": 154, - "src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts": 319, - "src/main/runtime/browser-host-lease-fenced-page-release.test.ts": 206, - "src/main/runtime/browser-host-lease-placement-retirement.test.ts": 189, - "src/main/runtime/browser-host-lease-registry.test.ts": 243, - "src/main/runtime/browser-host-page-placement-replacement.test.ts": 169, - "src/main/runtime/browser-host-page-placement.test.ts": 156, - "src/main/runtime/browser-host-page-reconciliation-executor.test.ts": 64, - "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts": 369, - "src/main/runtime/browser-host-page-reconciliation-plan.test.ts": 74, - "src/main/runtime/browser-host-page-reconciliation-preconditions.test.ts": 166, - "src/main/runtime/browser-host-page-retirement.test.ts": 32, - "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts": 2930, - "src/main/runtime/browser-screencast-driver-attribution.test.ts": 4314, - "src/main/runtime/browser-screencast-driver-scope.test.ts": 38, - "src/main/runtime/browser-screencast-ghost-subscriber-eviction.test.ts": 59, - "src/main/runtime/browser-screencast-remote-viewer-retention.test.ts": 3782, - "src/main/runtime/browser-session-tab-selection-snapshot.test.ts": 57, - "src/main/runtime/browser-tab-create-caller-navigation.test.ts": 3696, - "src/main/runtime/browser-tab-create-publication.test.ts": 775, - "src/main/runtime/claude-agent-teams-pty-exit-leak.test.ts": 3509, - "src/main/runtime/claude-agent-teams-service.test.ts": 86, - "src/main/runtime/claude-agent-teams-shim-env.test.ts": 102, - "src/main/runtime/claude-structured-failed-start-resume.test.ts": 1167, - "src/main/runtime/claude-structured-resumed-start-failure.test.ts": 1250, - "src/main/runtime/claude-structured-send-held-for-startup.test.ts": 1153, - "src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts": 1691, - "src/main/runtime/claude-structured-session-integration.test.ts": 4718, - "src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts": 1435, - "src/main/runtime/cli-terminal-create-host-session-binding.test.ts": 1988, - "src/main/runtime/client-hosted-browser-page-persistence.integration.test.ts": 3289, - "src/main/runtime/client-hosted-browser-page-persistence.test.ts": 233, - "src/main/runtime/client-hosted-browser-row-hydration-census.test.ts": 485, - "src/main/runtime/client-hosted-browser-row-projection.test.ts": 57, - "src/main/runtime/client-hosted-browser-row-publication.test.ts": 59, - "src/main/runtime/client-hosted-browser-row-push.integration.test.ts": 2544, - "src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts": 3363, - "src/main/runtime/client-hosted-page-reconciliation-window.test.ts": 57, - "src/main/runtime/client-session-tab-selection.test.ts": 57, - "src/main/runtime/decorative-title-fact-emission.test.ts": 33, - "src/main/runtime/exit-provenance-audit.test.ts": 3844, - "src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts": 2958, - "src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts": 4448, - "src/main/runtime/fetch-remote-cache.test.ts": 3566, - "src/main/runtime/file-watcher-host.test.ts": 816, - "src/main/runtime/fit-override-integration.test.ts": 3341, - "src/main/runtime/folder-workspace-pty-identity.test.ts": 2999, - "src/main/runtime/graph-sync-deletion-fence.test.ts": 3314, - "src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts": 3308, - "src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts": 9743, - "src/main/runtime/graph-sync-payload-partition.test.ts": 2285, - "src/main/runtime/headless-close-keeps-publication-epoch.test.ts": 2149, - "src/main/runtime/headless-hydration-ownership.test.ts": 4098, - "src/main/runtime/headless-placeholder-graph-surface-standing.test.ts": 2838, - "src/main/runtime/headless-seed-ownership.test.ts": 4790, - "src/main/runtime/headless-tab-group-split-layout.test.ts": 55, - "src/main/runtime/headless-tab-order-stability.test.ts": 2511, - "src/main/runtime/headless-terminal-dispose-write-ordering.test.ts": 3202, - "src/main/runtime/headless-terminal-query-reply-policy.test.ts": 49, - "src/main/runtime/headless-terminal-split-layout.test.ts": 34, - "src/main/runtime/hidden-output-restored-provider-snapshot.test.ts": 3364, - "src/main/runtime/host-terminal-close-persistence-durability.test.ts": 838, - "src/main/runtime/leaf-pty-verdict-expiry.test.ts": 3418, - "src/main/runtime/linear-save-issue.test.ts": 3157, - "src/main/runtime/managed-worktree-create-execution-host.test.ts": 2508, - "src/main/runtime/missing-worktree-terminal-reconciliation.test.ts": 197, - "src/main/runtime/mobile-agent-status-permission-renewal.test.ts": 3290, - "src/main/runtime/mobile-notification-dismissal-read-failure.test.ts": 78, - "src/main/runtime/mobile-notification-dismissal-store.test.ts": 73, - "src/main/runtime/mobile-notification-replay.test.ts": 77, - "src/main/runtime/mobile-pairing-qr.test.ts": 973, - "src/main/runtime/mobile-pairing-userdata-path.test.ts": 2117, - "src/main/runtime/mobile-presence-lock.test.ts": 15604, - "src/main/runtime/mobile-rpc-allowlist.test.ts": 1726, - "src/main/runtime/mobile-session-tab-close-refusal-message.test.ts": 54, - "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts": 44, - "src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts": 2760, - "src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts": 35, - "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts": 179, - "src/main/runtime/mobile-session-terminal-retirement-proof.test.ts": 37, - "src/main/runtime/mobile-session-terminal-retirement.test.ts": 153, - "src/main/runtime/mobile-subscribe-integration.test.ts": 18419, - "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 4776, - "src/main/runtime/muse-readiness-transcript.test.ts": 6923, - "src/main/runtime/opencode-finished-session-authority.test.ts": 3824, - "src/main/runtime/orca-runtime-agent-session-operation.test.ts": 2429, - "src/main/runtime/orca-runtime-agent-skill-share.test.ts": 2486, - "src/main/runtime/orca-runtime-automations.test.ts": 3439, - "src/main/runtime/orca-runtime-browser-client-hosted.test.ts": 425, - "src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts": 373, - "src/main/runtime/orca-runtime-browser-headless.test.ts": 784, - "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts": 429, - "src/main/runtime/orca-runtime-browser.test.ts": 747, - "src/main/runtime/orca-runtime-create-base-prefetch.test.ts": 3300, - "src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts": 2993, - "src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 2563, - "src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts": 439, - "src/main/runtime/orca-runtime-files-preview-budget.test.ts": 346, - "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 469, - "src/main/runtime/orca-runtime-files-search.test.ts": 325, - "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 436, - "src/main/runtime/orca-runtime-files-ssh-rearm.test.ts": 273, - "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 400, - "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 554, - "src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts": 501, - "src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts": 547, - "src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts": 362, - "src/main/runtime/orca-runtime-files-watch-host-scope.test.ts": 484, - "src/main/runtime/orca-runtime-files-watch.test.ts": 517, - "src/main/runtime/orca-runtime-git-branch-diff.test.ts": 732, - "src/main/runtime/orca-runtime-git-diff-budget.test.ts": 871, - "src/main/runtime/orca-runtime-git.test.ts": 464, - "src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts": 3261, - "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts": 2257, - "src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts": 2750, - "src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts": 3103, - "src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts": 3435, - "src/main/runtime/orca-runtime-module-size.test.ts": 31, - "src/main/runtime/orca-runtime-path-candidate-history.test.ts": 50, - "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts": 1851, - "src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts": 3259, - "src/main/runtime/orca-runtime-skill-recovery.test.ts": 2176, - "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 2913, - "src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts": 2897, - "src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts": 2908, - "src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts": 2429, - "src/main/runtime/orca-runtime-structured-session-restore.test.ts": 3480, - "src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts": 2007, - "src/main/runtime/orca-runtime-tab-id-collision.test.ts": 3392, - "src/main/runtime/orca-runtime-tail-wait-memo.test.ts": 2831, - "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts": 2299, - "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts": 3332, - "src/main/runtime/orca-runtime-terminal-cwd.test.ts": 3470, - "src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 2155, - "src/main/runtime/orca-runtime-terminal-rename-retention.test.ts": 2085, - "src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts": 3504, - "src/main/runtime/orca-runtime-terminal-retirement.test.ts": 3035, - "src/main/runtime/orca-runtime-terminal-split-authority.test.ts": 4293, - "src/main/runtime/orca-runtime-test-fragment-coverage.test.ts": 54, - "src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts": 3002, - "src/main/runtime/orca-runtime.test.ts": 20016, - "src/main/runtime/orchestration-cli-subprocess.test.ts": 4022, - "src/main/runtime/orchestration-codex-completion-title.test.ts": 4864, - "src/main/runtime/orchestration-codex-real-pty.integration.test.ts": 3073, - "src/main/runtime/orchestration-compatibility-authority.test.ts": 3514, - "src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts": 4228, - "src/main/runtime/orchestration-mailbox-cold-park-idle.test.ts": 4102, - "src/main/runtime/orchestration-mailbox-crash-recovery.test.ts": 3847, - "src/main/runtime/orchestration-mailbox-detached-routing.test.ts": 3792, - "src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts": 2765, - "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts": 15860, - "src/main/runtime/orchestration-mailbox-pointer-cli-command.test.ts": 3401, - "src/main/runtime/orchestration-mailbox-routing-races.test.ts": 4303, - "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts": 3583, - "src/main/runtime/orchestration-message-delivery-identity.test.ts": 4561, - "src/main/runtime/orchestration-messages-fake-parity.test.ts": 237, - "src/main/runtime/orchestration-worker-workspace-resolution.test.ts": 3441, - "src/main/runtime/orchestration/cli-command.test.ts": 45, - "src/main/runtime/orchestration/coordinator-decision-gates.test.ts": 435, - "src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts": 419, - "src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts": 503, - "src/main/runtime/orchestration/coordinator-escalation-triage.test.ts": 388, - "src/main/runtime/orchestration/coordinator-stale-base-flag.test.ts": 31, - "src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts": 245, - "src/main/runtime/orchestration/coordinator.test.ts": 3812, - "src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts": 355, - "src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts": 433, - "src/main/runtime/orchestration/db-message-timestamp.test.ts": 322, - "src/main/runtime/orchestration/db-messages.test.ts": 277, - "src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts": 391, - "src/main/runtime/orchestration/db-task-create-readiness.test.ts": 412, - "src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts": 954, - "src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts": 948, - "src/main/runtime/orchestration/db-task-dispatch-races.test.ts": 595, - "src/main/runtime/orchestration/db-undelivered-mailboxes.test.ts": 291, - "src/main/runtime/orchestration/db.test.ts": 1046, - "src/main/runtime/orchestration/db/attempt-outcome-projection.test.ts": 489, - "src/main/runtime/orchestration/db/decision-gate-lifecycle.test.ts": 216, - "src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts": 405, - "src/main/runtime/orchestration/db/dispatch-depth.test.ts": 608, - "src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts": 367, - "src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts": 592, - "src/main/runtime/orchestration/db/federated-worker-report-outcome.test.ts": 45, - "src/main/runtime/orchestration/db/federation/federated-dispatch-observation-fence.test.ts": 275, - "src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-release.test.ts": 382, - "src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts": 425, - "src/main/runtime/orchestration/db/legacy-question-identity.test.ts": 43, - "src/main/runtime/orchestration/db/lifecycle-rejection-marker.test.ts": 27, - "src/main/runtime/orchestration/db/lifecycle-transition-boundary.test.ts": 47, - "src/main/runtime/orchestration/db/lifecycle-transition.test.ts": 355, - "src/main/runtime/orchestration/db/messages/mailbox-consumer-lifecycle-fencing.test.ts": 732, - "src/main/runtime/orchestration/db/pane-key-match.test.ts": 33, - "src/main/runtime/orchestration/db/row-column-lists.test.ts": 397, - "src/main/runtime/orchestration/db/run-list-cursor.test.ts": 30, - "src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts": 408, - "src/main/runtime/orchestration/db/schema/derived-delivery-migration.test.ts": 669, - "src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts": 348, - "src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts": 440, - "src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts": 361, - "src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts": 261, - "src/main/runtime/orchestration/db/writer-run-required.test.ts": 365, - "src/main/runtime/orchestration/dispatch-consumer-generation-migration.test.ts": 498, - "src/main/runtime/orchestration/dispatch-creator-identity-migration.test.ts": 480, - "src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts": 434, - "src/main/runtime/orchestration/federation-ack-checkpoints.test.ts": 33, - "src/main/runtime/orchestration/federation-acknowledgment-integrity.test.ts": 277, - "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts": 373, - "src/main/runtime/orchestration/federation-lifecycle-settlement.test.ts": 23, - "src/main/runtime/orchestration/federation-sync.test.ts": 2820, - "src/main/runtime/orchestration/federation-terminal-recovery.test.ts": 3474, - "src/main/runtime/orchestration/formatter.test.ts": 61, - "src/main/runtime/orchestration/groups.test.ts": 42, - "src/main/runtime/orchestration/lifecycle-caller-edges.test.ts": 352, - "src/main/runtime/orchestration/lifecycle-reconciliation.test.ts": 786, - "src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts": 3611, - "src/main/runtime/orchestration/mailbox-pointer-eligibility.test.ts": 343, - "src/main/runtime/orchestration/mailbox-pointer-release-query.test.ts": 339, - "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts": 504, - "src/main/runtime/orchestration/mailbox-pointer-submit.test.ts": 696, - "src/main/runtime/orchestration/message-batch-atomicity.test.ts": 395, - "src/main/runtime/orchestration/mutation-receipt-capacity.test.ts": 400, - "src/main/runtime/orchestration/nested-worker-depth-migration.test.ts": 579, - "src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts": 1024, - "src/main/runtime/orchestration/orchestration-all-start-versions-migration.test.ts": 1741, - "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts": 1165, - "src/main/runtime/orchestration/orchestration-db-permissions.test.ts": 240, - "src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts": 706, - "src/main/runtime/orchestration/orchestration-delivery-consumption.test.ts": 464, - "src/main/runtime/orchestration/orchestration-derived-delivery.test.ts": 327, - "src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts": 500, - "src/main/runtime/orchestration/orchestration-legacy-coordinator-authority-db.test.ts": 712, - "src/main/runtime/orchestration/orchestration-legacy-question-migration-db.test.ts": 751, - "src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts": 1661, - "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts": 192, - "src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts": 289, - "src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts": 641, - "src/main/runtime/orchestration/orchestration-peer-capability-cache.test.ts": 74, - "src/main/runtime/orchestration/orchestration-reset-db.test.ts": 323, - "src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts": 532, - "src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts": 364, - "src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts": 1040, - "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts": 712, - "src/main/runtime/orchestration/preamble.test.ts": 144, - "src/main/runtime/orchestration/r1-identity-migration.test.ts": 407, - "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts": 520, - "src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts": 557, - "src/main/runtime/orchestration/settled-question-threads-migration.test.ts": 297, - "src/main/runtime/orchestration/setup-completion-signal.test.ts": 28, - "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts": 431, - "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts": 224, - "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts": 84, - "src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts": 73, - "src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts": 3260, - "src/main/runtime/orchestration/structured-worker-group-addressing.test.ts": 337, - "src/main/runtime/orchestration/structured-worker-journal-archive.test.ts": 91, - "src/main/runtime/orchestration/task-deps-flag.test.ts": 84, - "src/main/runtime/orchestration/worker-attention-context.test.ts": 58, - "src/main/runtime/orchestration/worker-output-archive-bounding.test.ts": 460, - "src/main/runtime/orchestration/worker-output-archive.test.ts": 386, - "src/main/runtime/orchestration/worker-output-cursor.test.ts": 65, - "src/main/runtime/orchestration/worker-provider-session.test.ts": 60, - "src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts": 394, - "src/main/runtime/orchestration/worker-transcript-payload.test.ts": 83, - "src/main/runtime/orchestration/worker-transcript-read.test.ts": 267, - "src/main/runtime/orchestration/worker-transcript-remote-read.test.ts": 201, - "src/main/runtime/paired-close-retirement-proof-publication-order.test.ts": 3296, - "src/main/runtime/pairing-endpoint.test.ts": 170, - "src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts": 34, - "src/main/runtime/pty-exit-agent-status-reconciliation.test.ts": 3395, - "src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts": 3330, - "src/main/runtime/pty-inventory-lifecycle-admission.test.ts": 2868, - "src/main/runtime/pty-inventory-lifecycle-scope.test.ts": 2608, - "src/main/runtime/pty-inventory-liveness-verdict.test.ts": 3469, - "src/main/runtime/pty-inventory-partial-relay-liveness.test.ts": 3435, - "src/main/runtime/pty-recorded-surface-topology.test.ts": 46, - "src/main/runtime/pty-shell-ownership-mirror.test.ts": 1555, - "src/main/runtime/pty-transcript-prune-wait-cache.test.ts": 3233, - "src/main/runtime/pty-waiver-source-invariant.test.ts": 50, - "src/main/runtime/public-ssh-state.test.ts": 27, - "src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts": 223, - "src/main/runtime/push/desktop-push-service.test.ts": 391, - "src/main/runtime/push/push-agent-state.test.ts": 54, - "src/main/runtime/push/push-cleanup-auth-expiry.test.ts": 219, - "src/main/runtime/push/push-delivery-policy.test.ts": 47, - "src/main/runtime/push/push-device-registration-persistence.test.ts": 85, - "src/main/runtime/push/push-dispatcher.test.ts": 73, - "src/main/runtime/push/push-gateway-client.test.ts": 230, - "src/main/runtime/push/push-gateway-session.test.ts": 312, - "src/main/runtime/push/push-host-proof-vector.test.ts": 84, - "src/main/runtime/push/push-host-proof.test.ts": 142, - "src/main/runtime/push/push-outcome-counters.test.ts": 34, - "src/main/runtime/push/push-policy-pipeline.integration.test.ts": 226, - "src/main/runtime/push/push-preferences.test.ts": 59, - "src/main/runtime/push/push-registration-races.test.ts": 320, - "src/main/runtime/push/push-registration-rpc.test.ts": 2997, - "src/main/runtime/push/push-unpair-persistence.test.ts": 4171, - "src/main/runtime/push/push-unregister-outbox.test.ts": 83, - "src/main/runtime/quarter-circle-title-send-authorization.test.ts": 19888, - "src/main/runtime/queued-terminal-graph-exit.test.ts": 3355, - "src/main/runtime/recent-pty-output-buffer.test.ts": 118, - "src/main/runtime/recent-pty-output-retention.test.ts": 101, - "src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts": 215, - "src/main/runtime/relay/desktop-relay-service.test.ts": 667, - "src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts": 281, - "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts": 166, - "src/main/runtime/relay/relay-auth-coordinator.test.ts": 1240, - "src/main/runtime/relay/relay-auth-host-close-reason.test.ts": 184, - "src/main/runtime/relay/relay-control-client.test.ts": 602, - "src/main/runtime/relay/relay-control-close-reason.test.ts": 293, - "src/main/runtime/relay/relay-control-origin.test.ts": 138, - "src/main/runtime/relay/relay-control-request-retirement.test.ts": 160, - "src/main/runtime/relay/relay-control-timeout-classification.test.ts": 159, - "src/main/runtime/relay/relay-demand-ledger.test.ts": 100, - "src/main/runtime/relay/relay-host-proof.test.ts": 131, - "src/main/runtime/relay/relay-http-client.test.ts": 341, - "src/main/runtime/relay/relay-region-correction.test.ts": 297, - "src/main/runtime/relay/relay-region-preference.test.ts": 180, - "src/main/runtime/relay/relay-region-probe-log.test.ts": 297, - "src/main/runtime/relay/relay-region-refresh.test.ts": 188, - "src/main/runtime/relay/relay-renewal-jitter.test.ts": 969, - "src/main/runtime/relay/relay-revoke-outbox.test.ts": 79, - "src/main/runtime/relay/relay-session-broker.test.ts": 667, - "src/main/runtime/remote-agent-session-host-authority.integration.test.ts": 3521, - "src/main/runtime/remote-browser-screencast-frame-admission.test.ts": 360, - "src/main/runtime/remote-desktop-driver.test.ts": 2835, - "src/main/runtime/remote-runtime-close-intent.integration.test.ts": 1447, - "src/main/runtime/remote-runtime-request-connection.integration.test.ts": 2224, - "src/main/runtime/remote-server-updater.test.ts": 39, - "src/main/runtime/renderer-browser-session-reconciliation.test.ts": 2331, - "src/main/runtime/repo-icon-fork-backfill.test.ts": 3128, - "src/main/runtime/repo-worktree-admin-fingerprint.test.ts": 856, + "src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts": 5685, + "src/main/runtime/agent-prompt-submission-runtime.test.ts": 6589, + "src/main/runtime/agent-prompt-submission-verification.test.ts": 140, + "src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts": 4945, + "src/main/runtime/agent-session-claim-identity.test.ts": 67, + "src/main/runtime/agent-session-conversation-clear-commit.test.ts": 705, + "src/main/runtime/agent-session-conversation-name-store.test.ts": 703, + "src/main/runtime/agent-session-death-evidence-persistence.test.ts": 730, + "src/main/runtime/agent-session-launch-directory.test.ts": 583, + "src/main/runtime/agent-session-launch-env-admission.test.ts": 812, + "src/main/runtime/agent-session-lease-renewal.test.ts": 706, + "src/main/runtime/agent-session-operation-admission.test.ts": 308, + "src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts": 88, + "src/main/runtime/agent-session-process-identity-probe.test.ts": 93, + "src/main/runtime/agent-session-provider-handle-transition.test.ts": 241, + "src/main/runtime/agent-session-record-conversation-name.test.ts": 296, + "src/main/runtime/agent-session-record-options.test.ts": 741, + "src/main/runtime/agent-session-record-store-slot.test.ts": 1888, + "src/main/runtime/agent-session-record-store-tab-table.test.ts": 847, + "src/main/runtime/agent-session-record-store.test.ts": 1364, + "src/main/runtime/agent-session-recovery-capsule.test.ts": 1090, + "src/main/runtime/agent-session-released-reservation-replay.test.ts": 851, + "src/main/runtime/agent-session-replaced-runtime.test.ts": 238, + "src/main/runtime/agent-session-reservation-admission.test.ts": 291, + "src/main/runtime/agent-session-runtime-attribution.test.ts": 799, + "src/main/runtime/agent-session-runtime-end-record.test.ts": 314, + "src/main/runtime/agent-session-spawn-token-process-scan.test.ts": 82, + "src/main/runtime/agent-session-spawn-token-readback.test.ts": 82, + "src/main/runtime/agent-session-store-transactions.test.ts": 575, + "src/main/runtime/agent-session-surface-release-transition.test.ts": 240, + "src/main/runtime/agent-session-unsaved-creation-supersession.test.ts": 730, + "src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts": 354, + "src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts": 239, + "src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts": 325, + "src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts": 470, + "src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts": 209, + "src/main/runtime/agent-terminal-launch-host.test.ts": 4780, + "src/main/runtime/agent-terminal-startup-prompt.test.ts": 4385, + "src/main/runtime/antigravity-readiness-transcripts.test.ts": 9127, + "src/main/runtime/antigravity-terminal-readiness.test.ts": 318, + "src/main/runtime/automatic-activation-host-sleep.test.ts": 5209, + "src/main/runtime/automation-change-publication.test.ts": 13953, + "src/main/runtime/browser-client-download-transfers.test.ts": 341, + "src/main/runtime/browser-dialog-settlement.test.ts": 415, + "src/main/runtime/browser-execution-host-key-resolution.test.ts": 4603, + "src/main/runtime/browser-host-capability-selection.test.ts": 270, + "src/main/runtime/browser-host-client-page-adoption-grants.test.ts": 56, + "src/main/runtime/browser-host-client-page-adoption.test.ts": 53, + "src/main/runtime/browser-host-client-page-creation.test.ts": 298, + "src/main/runtime/browser-host-command-ledger-capacity.test.ts": 233, + "src/main/runtime/browser-host-command-ledger.test.ts": 267, + "src/main/runtime/browser-host-file-channel-admission.test.ts": 228, + "src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts": 407, + "src/main/runtime/browser-host-lease-fenced-page-release.test.ts": 271, + "src/main/runtime/browser-host-lease-placement-retirement.test.ts": 299, + "src/main/runtime/browser-host-lease-registry.test.ts": 279, + "src/main/runtime/browser-host-page-placement-replacement.test.ts": 299, + "src/main/runtime/browser-host-page-placement.test.ts": 299, + "src/main/runtime/browser-host-page-reconciliation-executor.test.ts": 89, + "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts": 512, + "src/main/runtime/browser-host-page-reconciliation-plan.test.ts": 83, + "src/main/runtime/browser-host-page-reconciliation-preconditions.test.ts": 277, + "src/main/runtime/browser-host-page-retirement.test.ts": 58, + "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts": 5322, + "src/main/runtime/browser-screencast-driver-attribution.test.ts": 4877, + "src/main/runtime/browser-screencast-driver-scope.test.ts": 46, + "src/main/runtime/browser-screencast-ghost-subscriber-eviction.test.ts": 67, + "src/main/runtime/browser-screencast-remote-viewer-retention.test.ts": 5765, + "src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts": 5601, + "src/main/runtime/browser-session-tab-selection-snapshot.test.ts": 60, + "src/main/runtime/browser-tab-create-caller-navigation.test.ts": 4661, + "src/main/runtime/browser-tab-create-folder-workspace.test.ts": 407, + "src/main/runtime/browser-tab-create-publication.test.ts": 550, + "src/main/runtime/claude-agent-teams-pty-exit-leak.test.ts": 4658, + "src/main/runtime/claude-agent-teams-service.test.ts": 92, + "src/main/runtime/claude-agent-teams-shim-env.test.ts": 79, + "src/main/runtime/claude-structured-exit-mid-response.test.ts": 1386, + "src/main/runtime/claude-structured-failed-start-resume.test.ts": 1942, + "src/main/runtime/claude-structured-idle-releases-unknown.test.ts": 2200, + "src/main/runtime/claude-structured-resumed-start-failure.test.ts": 1787, + "src/main/runtime/claude-structured-retired-saved-model.test.ts": 1984, + "src/main/runtime/claude-structured-send-during-startup.test.ts": 2343, + "src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts": 2482, + "src/main/runtime/claude-structured-session-integration.test.ts": 3307, + "src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts": 1987, + "src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts": 2142, + "src/main/runtime/claude-trust-dialog-transcript.test.ts": 3395, + "src/main/runtime/cli-terminal-create-host-session-binding.test.ts": 4725, + "src/main/runtime/client-hosted-browser-page-persistence.integration.test.ts": 4478, + "src/main/runtime/client-hosted-browser-page-persistence.test.ts": 369, + "src/main/runtime/client-hosted-browser-row-projection.test.ts": 54, + "src/main/runtime/client-hosted-browser-row-publication.test.ts": 77, + "src/main/runtime/client-hosted-browser-row-push.integration.test.ts": 4403, + "src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts": 4758, + "src/main/runtime/client-hosted-page-reconciliation-window.test.ts": 44, + "src/main/runtime/client-session-tab-selection.test.ts": 68, + "src/main/runtime/cline-screen-readiness-transcripts.test.ts": 8000, + "src/main/runtime/codex-header-readiness-transcripts.test.ts": 5220, + "src/main/runtime/codex-quiet-ready-screen.test.ts": 8412, + "src/main/runtime/codex-startup-paste-transcripts.test.ts": 503, + "src/main/runtime/created-mobile-session-tab-caller-selection.test.ts": 4175, + "src/main/runtime/decorative-title-fact-emission.test.ts": 48, + "src/main/runtime/draft-paste-ready-scanner-chunking.test.ts": 1720, + "src/main/runtime/dsb-terminal-runtime.test.ts": 4441, + "src/main/runtime/dsh-readiness-transcript.test.ts": 70, + "src/main/runtime/e2ee-keypair.test.ts": 92, + "src/main/runtime/exit-provenance-audit.test.ts": 5831, + "src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts": 6032, + "src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts": 5078, + "src/main/runtime/fetch-remote-cache.test.ts": 4636, + "src/main/runtime/file-watcher-host.test.ts": 393, + "src/main/runtime/fit-override-integration.test.ts": 4532, + "src/main/runtime/floating-terminal-inventory-ownership.test.ts": 4508, + "src/main/runtime/folder-workspace-pty-identity.test.ts": 4443, + "src/main/runtime/graph-sync-deletion-fence.test.ts": 4769, + "src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts": 4728, + "src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts": 4118, + "src/main/runtime/graph-sync-payload-partition.test.ts": 4542, + "src/main/runtime/headless-close-keeps-publication-epoch.test.ts": 4883, + "src/main/runtime/headless-hydration-ownership.test.ts": 6383, + "src/main/runtime/headless-placeholder-graph-surface-standing.test.ts": 4474, + "src/main/runtime/headless-runtime-graph.test.ts": 4867, + "src/main/runtime/headless-seed-ownership.test.ts": 6450, + "src/main/runtime/headless-tab-group-selection.test.ts": 39, + "src/main/runtime/headless-tab-order-stability.test.ts": 5018, + "src/main/runtime/headless-terminal-dispose-write-ordering.test.ts": 4223, + "src/main/runtime/headless-terminal-query-reply-policy.test.ts": 55, + "src/main/runtime/headless-terminal-split-layout.test.ts": 62, + "src/main/runtime/hidden-output-restored-provider-snapshot.test.ts": 4953, + "src/main/runtime/host-descriptor.test.ts": 299, + "src/main/runtime/host-terminal-close-persistence-durability.test.ts": 1889, + "src/main/runtime/launch-agent-terminal-followup.test.ts": 4203, + "src/main/runtime/launched-agent-composer-readiness-claude.test.ts": 4003, + "src/main/runtime/launched-agent-composer-readiness.test.ts": 581, + "src/main/runtime/launched-agent-crash-guard.live-shell.test.ts": 209, + "src/main/runtime/launched-agent-exit-delivery.test.ts": 342, + "src/main/runtime/launched-agent-foreground.test.ts": 92, + "src/main/runtime/launched-agent-shell-handoff-readiness.test.ts": 101, + "src/main/runtime/launched-agent-write-guard.test.ts": 63, + "src/main/runtime/leaf-pty-verdict-expiry.test.ts": 5707, + "src/main/runtime/linear-save-issue.test.ts": 4330, + "src/main/runtime/managed-worktree-create-execution-host.test.ts": 4606, + "src/main/runtime/managed-worktree-create-telemetry.test.ts": 4411, + "src/main/runtime/missing-worktree-terminal-reconciliation.test.ts": 347, + "src/main/runtime/mobile-agent-status-permission-renewal.test.ts": 4833, + "src/main/runtime/mobile-notification-dismissal-read-failure.test.ts": 268, + "src/main/runtime/mobile-notification-dismissal-store.test.ts": 293, + "src/main/runtime/mobile-notification-replay.test.ts": 52, + "src/main/runtime/mobile-pairing-qr.test.ts": 1059, + "src/main/runtime/mobile-pairing-userdata-path.test.ts": 2373, + "src/main/runtime/mobile-presence-lock.test.ts": 16927, + "src/main/runtime/mobile-rpc-allowlist.test.ts": 3585, + "src/main/runtime/mobile-session-tab-close-refusal-message.test.ts": 46, + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts": 65, + "src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts": 4509, + "src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts": 67, + "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts": 331, + "src/main/runtime/mobile-session-terminal-retirement-proof.test.ts": 52, + "src/main/runtime/mobile-session-terminal-retirement.test.ts": 253, + "src/main/runtime/mobile-store-orphaned-temp-cleanup.test.ts": 381, + "src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts": 5383, + "src/main/runtime/mobile-subscribe-integration.test.ts": 18007, + "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 7135, + "src/main/runtime/native-chat-visuals-workspace-verdict.test.ts": 323, + "src/main/runtime/omp-readiness-transcripts.test.ts": 8110, + "src/main/runtime/opencode-agent-row-enter-transcript.test.ts": 559, + "src/main/runtime/opencode-agent-row-structure-transcript.test.ts": 333, + "src/main/runtime/opencode-finished-session-authority.test.ts": 5338, + "src/main/runtime/opencode-model-worktree-create.test.ts": 964, + "src/main/runtime/opencode-run-lifetime-runtime.test.ts": 4511, + "src/main/runtime/opencode-run-lifetime-status.test.ts": 131, + "src/main/runtime/orca-runtime-agent-session-operation.test.ts": 4398, + "src/main/runtime/orca-runtime-agent-skill-share.test.ts": 5202, + "src/main/runtime/orca-runtime-automations.test.ts": 4377, + "src/main/runtime/orca-runtime-browser-client-hosted.test.ts": 573, + "src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts": 519, + "src/main/runtime/orca-runtime-browser-headless.test.ts": 448, + "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts": 543, + "src/main/runtime/orca-runtime-browser.test.ts": 656, + "src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts": 4342, + "src/main/runtime/orca-runtime-create-base-prefetch.test.ts": 4314, + "src/main/runtime/orca-runtime-default-tab-agent-status.test.ts": 4293, + "src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts": 4356, + "src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 2713, + "src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts": 549, + "src/main/runtime/orca-runtime-files-preview-budget.test.ts": 634, + "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 542, + "src/main/runtime/orca-runtime-files-search.test.ts": 622, + "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 636, + "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 663, + "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 635, + "src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts": 617, + "src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts": 590, + "src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts": 674, + "src/main/runtime/orca-runtime-files-watch-host-scope.test.ts": 466, + "src/main/runtime/orca-runtime-files-watch.test.ts": 533, + "src/main/runtime/orca-runtime-git-branch-diff.test.ts": 1006, + "src/main/runtime/orca-runtime-git-diff-budget.test.ts": 952, + "src/main/runtime/orca-runtime-git.test.ts": 1054, + "src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts": 4653, + "src/main/runtime/orca-runtime-headless-terminal-close.test.ts": 4240, + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts": 4572, + "src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts": 4470, + "src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts": 4519, + "src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts": 4450, + "src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts": 4103, + "src/main/runtime/orca-runtime-path-candidate-history.test.ts": 64, + "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts": 5125, + "src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts": 4630, + "src/main/runtime/orca-runtime-quick-open-capabilities.test.ts": 618, + "src/main/runtime/orca-runtime-skill-recovery.test.ts": 4922, + "src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts": 5171, + "src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts": 4301, + "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 4720, + "src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts": 4032, + "src/main/runtime/orca-runtime-structured-session-restore.test.ts": 4748, + "src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts": 4449, + "src/main/runtime/orca-runtime-tab-id-collision.test.ts": 4395, + "src/main/runtime/orca-runtime-tail-wait-memo.test.ts": 303, + "src/main/runtime/orca-runtime-terminal-close-agent-launch.test.ts": 3986, + "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts": 4919, + "src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts": 4813, + "src/main/runtime/orca-runtime-terminal-close-records.test.ts": 6684, + "src/main/runtime/orca-runtime-terminal-close-spill-partition.test.ts": 4512, + "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts": 4787, + "src/main/runtime/orca-runtime-terminal-cwd.test.ts": 4884, + "src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 5490, + "src/main/runtime/orca-runtime-terminal-rename-retention.test.ts": 6075, + "src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts": 4772, + "src/main/runtime/orca-runtime-terminal-retirement.test.ts": 4562, + "src/main/runtime/orca-runtime-terminal-split-authority.test.ts": 4555, + "src/main/runtime/orca-runtime-terminal-surface-close.test.ts": 5472, + "src/main/runtime/orca-runtime-workspace-ports-host.test.ts": 3791, + "src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts": 4736, + "src/main/runtime/orca-runtime-worktree-provisioning-availability.test.ts": 5541, + "src/main/runtime/orca-runtime.test.ts": 36218, + "src/main/runtime/orcad-migration-catalog-import.test.ts": 45, + "src/main/runtime/orchestration-cli-subprocess.test.ts": 5861, + "src/main/runtime/orchestration-codex-completion-title.test.ts": 5337, + "src/main/runtime/orchestration-codex-real-pty.integration.test.ts": 6330, + "src/main/runtime/orchestration-compatibility-authority.test.ts": 4711, + "src/main/runtime/orchestration-configured-agent-target.test.ts": 4510, + "src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts": 6140, + "src/main/runtime/orchestration-live-agent-title-restore.test.ts": 5202, + "src/main/runtime/orchestration-mailbox-cold-park-idle.test.ts": 5151, + "src/main/runtime/orchestration-mailbox-crash-recovery.test.ts": 5866, + "src/main/runtime/orchestration-mailbox-detached-routing.test.ts": 5444, + "src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts": 6051, + "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts": 19774, + "src/main/runtime/orchestration-mailbox-pointer-cli-command.test.ts": 5540, + "src/main/runtime/orchestration-mailbox-routing-races.test.ts": 8786, + "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts": 5363, + "src/main/runtime/orchestration-message-delivery-identity.test.ts": 6283, + "src/main/runtime/orchestration-messages-fake-parity.test.ts": 619, + "src/main/runtime/orchestration-opencode-model-host.test.ts": 4210, + "src/main/runtime/orchestration-worker-workspace-resolution.test.ts": 4914, + "src/main/runtime/orchestration/agent-facing-parity.test.ts": 866, + "src/main/runtime/orchestration/chat-assignee.test.ts": 5175, + "src/main/runtime/orchestration/cli-command.test.ts": 55, + "src/main/runtime/orchestration/configured-worker-agent-selector.test.ts": 73, + "src/main/runtime/orchestration/coordinator-decision-gates.test.ts": 589, + "src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts": 614, + "src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts": 652, + "src/main/runtime/orchestration/coordinator-escalation-triage.test.ts": 533, + "src/main/runtime/orchestration/coordinator-stale-base-flag.test.ts": 41, + "src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts": 590, + "src/main/runtime/orchestration/coordinator.test.ts": 961, + "src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts": 609, + "src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts": 599, + "src/main/runtime/orchestration/db-message-timestamp.test.ts": 562, + "src/main/runtime/orchestration/db-messages.test.ts": 551, + "src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts": 640, + "src/main/runtime/orchestration/db-task-create-readiness.test.ts": 786, + "src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts": 1161, + "src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts": 1315, + "src/main/runtime/orchestration/db-task-dispatch-races.test.ts": 762, + "src/main/runtime/orchestration/db-task-promotion-projection.test.ts": 662, + "src/main/runtime/orchestration/db-undelivered-mailboxes.test.ts": 573, + "src/main/runtime/orchestration/db.test.ts": 1313, + "src/main/runtime/orchestration/db/attempt-outcome-projection.test.ts": 673, + "src/main/runtime/orchestration/db/decision-gate-lifecycle.test.ts": 527, + "src/main/runtime/orchestration/db/dispatch-depth.test.ts": 834, + "src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts": 627, + "src/main/runtime/orchestration/db/federated-worker-report-outcome.test.ts": 48, + "src/main/runtime/orchestration/db/federation/federated-dispatch-observation-fence.test.ts": 560, + "src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-release.test.ts": 633, + "src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts": 597, + "src/main/runtime/orchestration/db/legacy-question-identity.test.ts": 52, + "src/main/runtime/orchestration/db/lifecycle-rejection-marker.test.ts": 51, + "src/main/runtime/orchestration/db/lifecycle-transition.test.ts": 572, + "src/main/runtime/orchestration/db/messages/mailbox-consumer-lifecycle-fencing.test.ts": 809, + "src/main/runtime/orchestration/db/pane-key-match.test.ts": 51, + "src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts": 1139, + "src/main/runtime/orchestration/db/row-column-lists.test.ts": 554, + "src/main/runtime/orchestration/db/run-list-cursor.test.ts": 46, + "src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts": 720, + "src/main/runtime/orchestration/db/schema/derived-delivery-migration.test.ts": 810, + "src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts": 581, + "src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts": 620, + "src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts": 630, + "src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.test.ts": 627, + "src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts": 575, + "src/main/runtime/orchestration/db/writer-run-required.test.ts": 659, + "src/main/runtime/orchestration/dispatch-consumer-generation-migration.test.ts": 720, + "src/main/runtime/orchestration/dispatch-creator-identity-migration.test.ts": 649, + "src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts": 693, + "src/main/runtime/orchestration/federation-ack-checkpoints.test.ts": 38, + "src/main/runtime/orchestration/federation-acknowledgment-integrity.test.ts": 561, + "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts": 684, + "src/main/runtime/orchestration/federation-lifecycle-settlement.test.ts": 39, + "src/main/runtime/orchestration/federation-sync.test.ts": 5859, + "src/main/runtime/orchestration/federation-terminal-recovery.test.ts": 4138, + "src/main/runtime/orchestration/formatter.test.ts": 75, + "src/main/runtime/orchestration/groups.test.ts": 61, + "src/main/runtime/orchestration/lifecycle-caller-edges.test.ts": 653, + "src/main/runtime/orchestration/lifecycle-reconciliation.test.ts": 799, + "src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts": 5376, + "src/main/runtime/orchestration/mailbox-pointer-eligibility.test.ts": 666, + "src/main/runtime/orchestration/mailbox-pointer-release-query.test.ts": 591, + "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts": 691, + "src/main/runtime/orchestration/mailbox-pointer-submit.test.ts": 981, + "src/main/runtime/orchestration/message-batch-atomicity.test.ts": 641, + "src/main/runtime/orchestration/mutation-receipt-capacity.test.ts": 638, + "src/main/runtime/orchestration/nested-worker-depth-migration.test.ts": 676, + "src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts": 995, + "src/main/runtime/orchestration/orchestration-all-start-versions-migration.test.ts": 1990, + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts": 944, + "src/main/runtime/orchestration/orchestration-db-permissions.test.ts": 573, + "src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts": 776, + "src/main/runtime/orchestration/orchestration-delivery-consumption.test.ts": 643, + "src/main/runtime/orchestration/orchestration-derived-delivery.test.ts": 567, + "src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts": 868, + "src/main/runtime/orchestration/orchestration-legacy-coordinator-authority-db.test.ts": 956, + "src/main/runtime/orchestration/orchestration-legacy-question-migration-db.test.ts": 1066, + "src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts": 2229, + "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts": 347, + "src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts": 660, + "src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts": 957, + "src/main/runtime/orchestration/orchestration-party-cli-address.test.ts": 281, + "src/main/runtime/orchestration/orchestration-party-location.test.ts": 616, + "src/main/runtime/orchestration/orchestration-peer-capability-cache.test.ts": 79, + "src/main/runtime/orchestration/orchestration-reset-db.test.ts": 588, + "src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts": 768, + "src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts": 593, + "src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts": 1120, + "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts": 822, + "src/main/runtime/orchestration/preamble.test.ts": 174, + "src/main/runtime/orchestration/r1-identity-migration.test.ts": 634, + "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts": 691, + "src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts": 693, + "src/main/runtime/orchestration/send-agent-turn-host.test.ts": 1639, + "src/main/runtime/orchestration/send-agent-turn.test.ts": 267, + "src/main/runtime/orchestration/settled-question-threads-migration.test.ts": 570, + "src/main/runtime/orchestration/setup-completion-signal.test.ts": 54, + "src/main/runtime/orchestration/structured-mail-source.test.ts": 302, + "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts": 664, + "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts": 401, + "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts": 91, + "src/main/runtime/orchestration/structured-session-mail-target.test.ts": 4683, + "src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts": 288, + "src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts": 4219, + "src/main/runtime/orchestration/structured-worker-group-addressing.test.ts": 513, + "src/main/runtime/orchestration/structured-worker-journal-archive.test.ts": 354, + "src/main/runtime/orchestration/structured-worker-journal-page.test.ts": 978, + "src/main/runtime/orchestration/task-deps-flag.test.ts": 70, + "src/main/runtime/orchestration/worker-attention-context.test.ts": 89, + "src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts": 2467, + "src/main/runtime/orchestration/worker-dispatch-settlement.test.ts": 1181, + "src/main/runtime/orchestration/worker-output-archive-bounding.test.ts": 794, + "src/main/runtime/orchestration/worker-output-archive.test.ts": 801, + "src/main/runtime/orchestration/worker-output-cursor.test.ts": 51, + "src/main/runtime/orchestration/worker-provider-session.test.ts": 59, + "src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts": 506, + "src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts": 58, + "src/main/runtime/orchestration/worker-transcript-payload.test.ts": 319, + "src/main/runtime/orchestration/worker-transcript-read.test.ts": 844, + "src/main/runtime/orchestration/worker-transcript-remote-read.test.ts": 668, + "src/main/runtime/paired-close-retirement-proof-publication-order.test.ts": 4291, + "src/main/runtime/pairing-endpoint.test.ts": 264, + "src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts": 7440, + "src/main/runtime/project-host-setup-delete-guard.test.ts": 387, + "src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts": 41, + "src/main/runtime/pty-exit-agent-status-reconciliation.test.ts": 4014, + "src/main/runtime/pty-exit-record-wait.test.ts": 4307, + "src/main/runtime/pty-inventory-lifecycle-admission.test.ts": 4897, + "src/main/runtime/pty-inventory-lifecycle-scope.test.ts": 5629, + "src/main/runtime/pty-inventory-liveness-verdict.test.ts": 4512, + "src/main/runtime/pty-inventory-partial-relay-liveness.test.ts": 4555, + "src/main/runtime/pty-lifecycle-generation-retention.test.ts": 5736, + "src/main/runtime/pty-recorded-surface-topology.test.ts": 47, + "src/main/runtime/pty-shell-ownership-mirror.test.ts": 1615, + "src/main/runtime/pty-transcript-prune-wait-cache.test.ts": 4405, + "src/main/runtime/public-ssh-state.test.ts": 42, + "src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts": 369, + "src/main/runtime/push/desktop-push-service.test.ts": 455, + "src/main/runtime/push/push-agent-state.test.ts": 61, + "src/main/runtime/push/push-cleanup-auth-expiry.test.ts": 392, + "src/main/runtime/push/push-delivery-policy.test.ts": 75, + "src/main/runtime/push/push-device-registration-persistence.test.ts": 129, + "src/main/runtime/push/push-dispatcher.test.ts": 84, + "src/main/runtime/push/push-gateway-client.test.ts": 415, + "src/main/runtime/push/push-gateway-session.test.ts": 392, + "src/main/runtime/push/push-host-proof-vector.test.ts": 60, + "src/main/runtime/push/push-host-proof.test.ts": 155, + "src/main/runtime/push/push-outcome-counters.test.ts": 39, + "src/main/runtime/push/push-policy-pipeline.integration.test.ts": 358, + "src/main/runtime/push/push-preferences.test.ts": 66, + "src/main/runtime/push/push-registration-races.test.ts": 398, + "src/main/runtime/push/push-registration-rpc.test.ts": 5420, + "src/main/runtime/push/push-unpair-persistence.test.ts": 5651, + "src/main/runtime/push/push-unregister-outbox.test.ts": 91, + "src/main/runtime/qoder-independent-spawn-review.test.ts": 4429, + "src/main/runtime/qoder-mobile-resume-adoption.test.ts": 6484, + "src/main/runtime/qoder-terminal-command-resolution.test.ts": 148, + "src/main/runtime/quarter-circle-title-send-authorization.test.ts": 16020, + "src/main/runtime/queued-terminal-graph-exit.test.ts": 5194, + "src/main/runtime/recent-pty-output-buffer.test.ts": 137, + "src/main/runtime/recent-pty-output-retention.test.ts": 160, + "src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts": 306, + "src/main/runtime/relay/desktop-relay-service-installer.test.ts": 48, + "src/main/runtime/relay/desktop-relay-service.test.ts": 743, + "src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts": 526, + "src/main/runtime/relay/relay-auth-coordinator-readiness.test.ts": 526, + "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts": 326, + "src/main/runtime/relay/relay-auth-coordinator.test.ts": 1435, + "src/main/runtime/relay/relay-auth-host-close-reason.test.ts": 355, + "src/main/runtime/relay/relay-control-client.test.ts": 788, + "src/main/runtime/relay/relay-control-close-reason.test.ts": 339, + "src/main/runtime/relay/relay-control-origin.test.ts": 284, + "src/main/runtime/relay/relay-control-request-retirement.test.ts": 296, + "src/main/runtime/relay/relay-control-timeout-classification.test.ts": 244, + "src/main/runtime/relay/relay-demand-ledger.test.ts": 98, + "src/main/runtime/relay/relay-host-proof.test.ts": 166, + "src/main/runtime/relay/relay-http-client.test.ts": 279, + "src/main/runtime/relay/relay-region-correction.test.ts": 312, + "src/main/runtime/relay/relay-region-preference.test.ts": 352, + "src/main/runtime/relay/relay-region-probe-log.test.ts": 275, + "src/main/runtime/relay/relay-region-refresh.test.ts": 249, + "src/main/runtime/relay/relay-renewal-jitter.test.ts": 254, + "src/main/runtime/relay/relay-revoke-outbox.test.ts": 102, + "src/main/runtime/relay/relay-session-broker.test.ts": 859, + "src/main/runtime/remote-agent-session-host-authority.integration.test.ts": 5984, + "src/main/runtime/remote-browser-screencast-frame-admission.test.ts": 409, + "src/main/runtime/remote-desktop-driver.test.ts": 4594, + "src/main/runtime/remote-desktop-terminal-floor-reconnect.test.ts": 46, + "src/main/runtime/remote-runtime-close-intent.integration.test.ts": 4011, + "src/main/runtime/remote-runtime-request-connection.integration.test.ts": 3568, + "src/main/runtime/remote-server-updater.test.ts": 51, + "src/main/runtime/renderer-browser-session-reconciliation.test.ts": 2554, + "src/main/runtime/repo-icon-fork-backfill.test.ts": 4230, + "src/main/runtime/repo-worktree-admin-fingerprint.test.ts": 813, "src/main/runtime/repo-worktree-resolution-scan.test.ts": 54, - "src/main/runtime/repo-worktree-row-resolution.test.ts": 221, - "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts": 3753, - "src/main/runtime/rpc/core-typed-method-contract.test.ts": 107, - "src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts": 1502, - "src/main/runtime/rpc/dispatcher-computer-errors.test.ts": 2001, - "src/main/runtime/rpc/dispatcher-feature-interactions.test.ts": 1855, - "src/main/runtime/rpc/dispatcher-request-parsing.test.ts": 143, - "src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts": 183, - "src/main/runtime/rpc/e2ee-channel-v2.test.ts": 226, - "src/main/runtime/rpc/e2ee-channel.test.ts": 490, - "src/main/runtime/rpc/e2ee-crypto.test.ts": 99, - "src/main/runtime/rpc/e2ee-integration.test.ts": 267, - "src/main/runtime/rpc/errors.test.ts": 190, - "src/main/runtime/rpc/methods/accounts.test.ts": 152, - "src/main/runtime/rpc/methods/agent-hooks.test.ts": 138, - "src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts": 2274, - "src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts": 1814, - "src/main/runtime/rpc/methods/agent-launch-replay.test.ts": 2245, - "src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts": 925, - "src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts": 72, - "src/main/runtime/rpc/methods/agent-launch-terminal-identity.test.ts": 349, - "src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts": 301, - "src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts": 93, - "src/main/runtime/rpc/methods/agent-launch.test.ts": 545, - "src/main/runtime/rpc/methods/agent-session.test.ts": 1701, - "src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts": 2731, - "src/main/runtime/rpc/methods/ai-vault-search.test.ts": 3943, - "src/main/runtime/rpc/methods/ai-vault.test.ts": 3245, - "src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts": 2728, - "src/main/runtime/rpc/methods/artifacts.test.ts": 419, - "src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts": 2331, - "src/main/runtime/rpc/methods/automations.test.ts": 1969, - "src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts": 1420, - "src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts": 1886, - "src/main/runtime/rpc/methods/browser-client-host.test.ts": 1978, - "src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts": 2040, - "src/main/runtime/rpc/methods/browser-identity-rpc.test.ts": 2038, - "src/main/runtime/rpc/methods/browser-network-tunnel.test.ts": 2142, - "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts": 75, - "src/main/runtime/rpc/methods/browser.test.ts": 2100, - "src/main/runtime/rpc/methods/client-events.test.ts": 1168, - "src/main/runtime/rpc/methods/client-native-chat-settings.test.ts": 2581, - "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": 2057, - "src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts": 1201, - "src/main/runtime/rpc/methods/client-ui.test.ts": 5509, - "src/main/runtime/rpc/methods/clipboard.test.ts": 1919, - "src/main/runtime/rpc/methods/computer-actions.test.ts": 203, - "src/main/runtime/rpc/methods/computer.test.ts": 185, - "src/main/runtime/rpc/methods/diagnostics.test.ts": 2205, - "src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts": 78, - "src/main/runtime/rpc/methods/files-base64-padding.test.ts": 1950, - "src/main/runtime/rpc/methods/files-doc-preview.test.ts": 2034, - "src/main/runtime/rpc/methods/files-list-all-page-size.test.ts": 2043, - "src/main/runtime/rpc/methods/files-path-search.test.ts": 1362, - "src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts": 1828, - "src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts": 2031, - "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts": 2085, - "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts": 2050, - "src/main/runtime/rpc/methods/files.test.ts": 2096, - "src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts": 1619, - "src/main/runtime/rpc/methods/git.test.ts": 1380, - "src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts": 1972, - "src/main/runtime/rpc/methods/github.test.ts": 1972, - "src/main/runtime/rpc/methods/gitlab.test.ts": 2078, - "src/main/runtime/rpc/methods/host-capabilities.test.ts": 2054, - "src/main/runtime/rpc/methods/hosted-review.test.ts": 1980, - "src/main/runtime/rpc/methods/jira.test.ts": 1754, - "src/main/runtime/rpc/methods/linear-agent-access.test.ts": 3679, - "src/main/runtime/rpc/methods/linear-agent-project-access.test.ts": 4428, - "src/main/runtime/rpc/methods/linear.test.ts": 2000, - "src/main/runtime/rpc/methods/mobile-web-bundle-range.test.ts": 2006, - "src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts": 1627, - "src/main/runtime/rpc/methods/mobile-web-bundle-window-refusals.test.ts": 1956, - "src/main/runtime/rpc/methods/mobile-web-bundle.test.ts": 2278, - "src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts": 59, - "src/main/runtime/rpc/methods/native-chat.test.ts": 205, - "src/main/runtime/rpc/methods/notification-preferences.test.ts": 187, - "src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts": 125, - "src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts": 3127, - "src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts": 3454, - "src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts": 177, - "src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts": 2861, - "src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts": 202, - "src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts": 257, - "src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts": 3575, - "src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts": 3617, - "src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts": 221, - "src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts": 181, - "src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts": 146, - "src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts": 2942, - "src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.test.ts": 59, - "src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.test.ts": 306, - "src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts": 3006, - "src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts": 3590, - "src/main/runtime/rpc/methods/orchestration/federation/federated-transport-safety.test.ts": 3305, - "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts": 3044, - "src/main/runtime/rpc/methods/orchestration/federation/federation-agent-launch.test.ts": 3886, - "src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts": 2200, - "src/main/runtime/rpc/methods/orchestration/federation/federation-effects.test.ts": 48, - "src/main/runtime/rpc/methods/orchestration/federation/federation-folder-placement.test.ts": 3459, - "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts": 8330, - "src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts": 3459, - "src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts": 5069, - "src/main/runtime/rpc/methods/orchestration/federation/federation-setup.test.ts": 3127, - "src/main/runtime/rpc/methods/orchestration/federation/federation-start-prompt-budget.test.ts": 3354, - "src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts": 135, - "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts": 5899, - "src/main/runtime/rpc/methods/orchestration/gates/gate-run-authorization.test.ts": 4327, - "src/main/runtime/rpc/methods/orchestration/gates/gates.test.ts": 4254, - "src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts": 3642, - "src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts": 3832, - "src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts": 2154, - "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts": 3774, - "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts": 2399, - "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts": 4320, - "src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts": 3153, - "src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts": 3500, - "src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts": 1944, - "src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts": 3325, - "src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts": 3305, - "src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts": 3760, - "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts": 3572, - "src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts": 3495, - "src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts": 3989, - "src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts": 170, - "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts": 3653, - "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts": 3767, - "src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts": 5044, - "src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts": 3631, - "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts": 3335, - "src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts": 3459, - "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts": 2831, - "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts": 2477, - "src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts": 3377, - "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts": 3653, - "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts": 2997, - "src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts": 3581, - "src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts": 301, - "src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts": 2671, - "src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts": 185, - "src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts": 2921, - "src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts": 3483, - "src/main/runtime/rpc/methods/orchestration/worker/worker-observation.test.ts": 226, - "src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts": 189, - "src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts": 2982, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts": 2904, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts": 3642, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-liveness-verdict.test.ts": 386, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts": 2795, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts": 3055, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts": 3085, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts": 3192, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts": 37, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-outcome-classification.test.ts": 306, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts": 103, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts": 3639, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts": 3746, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts": 47, - "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-capability.test.ts": 394, - "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-exit-race.test.ts": 2902, - "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts": 2444, - "src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-custody-at-creation.test.ts": 3618, - "src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts": 4522, - "src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts": 2613, - "src/main/runtime/rpc/methods/paired-caller-host-id.test.ts": 57, - "src/main/runtime/rpc/methods/pairing.test.ts": 4021, - "src/main/runtime/rpc/methods/plugins.test.ts": 357, - "src/main/runtime/rpc/methods/preflight.test.ts": 1474, - "src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts": 147, - "src/main/runtime/rpc/methods/repo-badge-color.test.ts": 1628, - "src/main/runtime/rpc/methods/repo.test.ts": 3091, - "src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts": 2076, - "src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts": 1544, - "src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts": 72, - "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts": 1954, - "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts": 43, - "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts": 7364, - "src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts": 3424, - "src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts": 3387, - "src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts": 1812, - "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts": 214, - "src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts": 1499, - "src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts": 1741, - "src/main/runtime/rpc/methods/session-tabs.test.ts": 2523, - "src/main/runtime/rpc/methods/skills.test.ts": 145, - "src/main/runtime/rpc/methods/speech.test.ts": 2115, - "src/main/runtime/rpc/methods/ssh.test.ts": 2113, - "src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts": 4507, - "src/main/runtime/rpc/methods/status.test.ts": 3576, - "src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts": 1748, - "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts": 3622, - "src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts": 1976, - "src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.test.ts": 2085, - "src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts": 5554, - "src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts": 1458, - "src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 56, - "src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 2074, - "src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts": 2079, - "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts": 1973, - "src/main/runtime/rpc/methods/structured-agent-session.test.ts": 3566, - "src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts": 4222, - "src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts": 278, - "src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts": 3571, - "src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts": 3424, - "src/main/runtime/rpc/methods/task-provider-identity.test.ts": 199, - "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts": 243, - "src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts": 1363, - "src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts": 218, - "src/main/runtime/rpc/methods/terminal-navigation.test.ts": 1906, - "src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts": 257, - "src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts": 99, - "src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts": 171, - "src/main/runtime/rpc/methods/terminal/terminal-output-batcher.test.ts": 56, - "src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts": 440, - "src/main/runtime/rpc/methods/updater.test.ts": 96, - "src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts": 174, - "src/main/runtime/rpc/methods/workspace-ports.test.ts": 1990, - "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 1995, - "src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts": 1225, - "src/main/runtime/rpc/methods/worktree-create-args.test.ts": 229, - "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 2179, - "src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts": 1632, - "src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts": 2053, - "src/main/runtime/rpc/methods/worktree-retired-names.test.ts": 2044, - "src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts": 1170, - "src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts": 1653, - "src/main/runtime/rpc/methods/worktree-schemas.test.ts": 213, - "src/main/runtime/rpc/methods/worktree.test.ts": 1890, - "src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts": 738, - "src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts": 75, - "src/main/runtime/rpc/mobile-e2ee-outbound-memory-budget.test.ts": 39, - "src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts": 88, - "src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts": 58, - "src/main/runtime/rpc/mobile-socket-wiring.test.ts": 407, - "src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts": 6095, - "src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts": 4449, - "src/main/runtime/rpc/orchestration-contract-fence.test.ts": 3677, - "src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts": 4165, - "src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts": 4292, - "src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts": 5243, - "src/main/runtime/rpc/orchestration-legacy-fence-jurisdiction.test.ts": 4116, - "src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts": 4209, - "src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts": 4083, - "src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts": 3570, - "src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts": 3472, - "src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts": 3978, - "src/main/runtime/rpc/orchestration-mutation-executor.test.ts": 2234, - "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts": 4386, - "src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts": 3422, - "src/main/runtime/rpc/orchestration-mutation-request-show.test.ts": 3415, - "src/main/runtime/rpc/orchestration-party-addressing.test.ts": 2984, - "src/main/runtime/rpc/orchestration-party-agreement.test.ts": 4332, - "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts": 3707, - "src/main/runtime/rpc/orchestration-session-caller.test.ts": 3095, - "src/main/runtime/rpc/orchestration-session-coordinator.test.ts": 4617, - "src/main/runtime/rpc/orchestration-session-recipient.test.ts": 3497, - "src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts": 4509, - "src/main/runtime/rpc/relay-transport.test.ts": 232, - "src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts": 66, - "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts": 66, - "src/main/runtime/rpc/runtime-client-capabilities.test.ts": 51, - "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts": 1449, - "src/main/runtime/rpc/runtime-close-attribution.test.ts": 1499, - "src/main/runtime/rpc/schemas.test.ts": 342, - "src/main/runtime/rpc/static-web-client-handler-stream.test.ts": 4560, - "src/main/runtime/rpc/streaming.test.ts": 2073, - "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 1690, - "src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 2687, - "src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 1909, - "src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 1995, - "src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts": 1877, - "src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 2532, - "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts": 3965, - "src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts": 1743, - "src/main/runtime/rpc/terminal-multiplex-end-verdict.test.ts": 2073, - "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts": 1942, - "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts": 42, - "src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts": 2257, - "src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts": 2087, - "src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts": 2541, - "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts": 2115, - "src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts": 2070, - "src/main/runtime/rpc/terminal-multiplex-round-robin.test.ts": 53, - "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts": 2326, - "src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts": 2706, - "src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts": 1321, - "src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts": 4114, - "src/main/runtime/rpc/terminal-output-batching.test.ts": 1858, - "src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts": 24855, - "src/main/runtime/rpc/terminal-output-frame-source-ranges.test.ts": 48, - "src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts": 2576, - "src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts": 2196, - "src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts": 2186, - "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts": 1892, - "src/main/runtime/rpc/terminal-send.test.ts": 2332, - "src/main/runtime/rpc/terminal-source-range-ledger.test.ts": 56, - "src/main/runtime/rpc/terminal-stream-byte-length.test.ts": 6429, - "src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts": 1622, - "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts": 8969, - "src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts": 2041, - "src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts": 1193, - "src/main/runtime/rpc/terminal-subscribe-ownership.test.ts": 2029, - "src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts": 1967, - "src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts": 3468, - "src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts": 1930, - "src/main/runtime/rpc/unix-socket-transport.test.ts": 88, - "src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts": 37, - "src/main/runtime/rpc/worktree-catalog-snapshot.test.ts": 65, - "src/main/runtime/rpc/ws-fallback-port-store.test.ts": 43, - "src/main/runtime/rpc/ws-transport-accept-order.test.ts": 157, - "src/main/runtime/rpc/ws-transport-static-web.test.ts": 218, - "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts": 721, - "src/main/runtime/rpc/ws-transport.test.ts": 1076, - "src/main/runtime/runtime-agent-row-main-agent-projection.test.ts": 51, - "src/main/runtime/runtime-binary-message-router.test.ts": 36, - "src/main/runtime/runtime-browser-client-automation.test.ts": 181, - "src/main/runtime/runtime-browser-client-page-adoption.test.ts": 210, - "src/main/runtime/runtime-browser-client-page-creation.test.ts": 126, - "src/main/runtime/runtime-browser-client-page-recovery.test.ts": 250, - "src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts": 116, - "src/main/runtime/runtime-browser-network-execution-host.test.ts": 43, - "src/main/runtime/runtime-browser-page-registry.test.ts": 57, - "src/main/runtime/runtime-client-settings-linear-team-projection.test.ts": 453, - "src/main/runtime/runtime-client-settings-minimax-projection.test.ts": 289, - "src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts": 429, - "src/main/runtime/runtime-extraction-regressions.test.ts": 3371, - "src/main/runtime/runtime-file-path-existence.test.ts": 1374, - "src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts": 37, - "src/main/runtime/runtime-file-target-execution-host.test.ts": 3281, - "src/main/runtime/runtime-file-upload-decode.test.ts": 3770, - "src/main/runtime/runtime-folder-workspace.test.ts": 50, - "src/main/runtime/runtime-folder-worktree-create.test.ts": 71, - "src/main/runtime/runtime-git-api-contract.test.ts": 722, - "src/main/runtime/runtime-git-branch-compare-admission.test.ts": 66, - "src/main/runtime/runtime-git-command-target.test.ts": 34, - "src/main/runtime/runtime-git-conflict-operation-routing.test.ts": 501, - "src/main/runtime/runtime-git-execution-host-ownership.test.ts": 819, - "src/main/runtime/runtime-git-generation-admission.test.ts": 132, - "src/main/runtime/runtime-git-status-admission.test.ts": 2074, - "src/main/runtime/runtime-git-sync-commands.test.ts": 795, - "src/main/runtime/runtime-git-target-execution-host.test.ts": 2543, - "src/main/runtime/runtime-graph-reload-lifecycle.test.ts": 39, - "src/main/runtime/runtime-hook-agent-row-selection.test.ts": 91, - "src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts": 40, - "src/main/runtime/runtime-linear-read-commands.test.ts": 118, - "src/main/runtime/runtime-local-create-rearm-ordering.test.ts": 3195, - "src/main/runtime/runtime-local-worktree-create.test.ts": 258, - "src/main/runtime/runtime-local-worktree-materialization.test.ts": 71, - "src/main/runtime/runtime-local-worktree-terminal-startup.test.ts": 46, - "src/main/runtime/runtime-machine-name.test.ts": 76, - "src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 1074, - "src/main/runtime/runtime-managed-worktree-metadata.test.ts": 75, - "src/main/runtime/runtime-managed-worktree-queries.test.ts": 275, - "src/main/runtime/runtime-metadata-ownership-watch.test.ts": 119, - "src/main/runtime/runtime-metadata.test.ts": 168, - "src/main/runtime/runtime-mobile-agent-status-builder.test.ts": 62, - "src/main/runtime/runtime-mobile-file-path-search.test.ts": 32, - "src/main/runtime/runtime-owned-terminal-publication-lineage.test.ts": 3962, - "src/main/runtime/runtime-project-group-controller-folder-delete.test.ts": 53, - "src/main/runtime/runtime-project-host-setup-controller.test.ts": 328, - "src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 292, - "src/main/runtime/runtime-remote-managed-worktree-create.test.ts": 42, - "src/main/runtime/runtime-remove-project-host-scope.test.ts": 2613, - "src/main/runtime/runtime-repository-issue-command.test.ts": 280, - "src/main/runtime/runtime-resolved-worktree-cache.test.ts": 54, - "src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 4251, - "src/main/runtime/runtime-rpc-device-revocation.test.ts": 4197, - "src/main/runtime/runtime-rpc-long-poll-transport.test.ts": 6862, - "src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts": 3056, - "src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts": 1796, - "src/main/runtime/runtime-rpc-mobile-native-chat-settings.test.ts": 29, - "src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts": 4788, - "src/main/runtime/runtime-rpc-mobile-unknown-method-scope-refusal.test.ts": 1513, - "src/main/runtime/runtime-rpc-orchestration-db-migration.test.ts": 424, - "src/main/runtime/runtime-rpc-pairing-mode-persistence.test.ts": 3549, - "src/main/runtime/runtime-rpc-pairing-offer.test.ts": 4433, - "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 4084, - "src/main/runtime/runtime-rpc-request-authorization.test.ts": 4122, - "src/main/runtime/runtime-rpc-startup-failure.test.ts": 66, - "src/main/runtime/runtime-rpc-terminal-list.test.ts": 4158, - "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 4774, - "src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts": 4036, - "src/main/runtime/runtime-rpc-worktree-queries.test.ts": 4031, - "src/main/runtime/runtime-search-line-fragments.test.ts": 264, - "src/main/runtime/runtime-server-git-availability.test.ts": 60, - "src/main/runtime/runtime-session-search-settings.test.ts": 66, - "src/main/runtime/runtime-skill-install-authority.test.ts": 64, - "src/main/runtime/runtime-skill-install-commands.test.ts": 428, - "src/main/runtime/runtime-skill-install-queries.test.ts": 263, - "src/main/runtime/runtime-socket-sweep.test.ts": 2436, - "src/main/runtime/runtime-subscription-registry.test.ts": 30, - "src/main/runtime/runtime-terminal-idle-polls.test.ts": 62, - "src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts": 44, - "src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts": 93, - "src/main/runtime/runtime-unregistered-worktree-removal-host-home.test.ts": 617, - "src/main/runtime/runtime-worktree-agent-rows-structured.test.ts": 473, - "src/main/runtime/runtime-worktree-agent-sources.test.ts": 49, - "src/main/runtime/runtime-worktree-agent-startup.test.ts": 166, - "src/main/runtime/runtime-worktree-ps-summaries.test.ts": 40, - "src/main/runtime/runtime-worktree-selection.test.ts": 53, - "src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts": 663, - "src/main/runtime/saved-structured-agent-session-restoration.test.ts": 24, - "src/main/runtime/selected-review-branch.test.ts": 44, - "src/main/runtime/session-tabs-inventory-publication.test.ts": 3283, - "src/main/runtime/settled-worker-process-replacement.test.ts": 2400, - "src/main/runtime/structured-agent-session-close.test.ts": 115, - "src/main/runtime/structured-agent-session-integration-replay.test.ts": 2654, - "src/main/runtime/structured-agent-session-integration.test.ts": 11330, - "src/main/runtime/structured-agent-session-rollback-compatibility.test.ts": 352, - "src/main/runtime/structured-agent-session-runtime-exit.test.ts": 1281, - "src/main/runtime/structured-agent-session-runtime-provider-started.test.ts": 1074, - "src/main/runtime/structured-agent-session-runtime.test.ts": 861, - "src/main/runtime/structured-agent-session-support-probe.test.ts": 3514, - "src/main/runtime/structured-agent-shell-environment.test.ts": 57, - "src/main/runtime/structured-claude-auth-policy-wiring.test.ts": 777, - "src/main/runtime/structured-claude-pending-rewind.test.ts": 1026, - "src/main/runtime/structured-conversation-tab-replacement.test.ts": 30, - "src/main/runtime/structured-session-worktree-teardown.test.ts": 3171, - "src/main/runtime/structured-worker-agent-presence.test.ts": 89, - "src/main/runtime/structured-worker-authority.test.ts": 207, - "src/main/runtime/structured-worker-child-identity-env.test.ts": 213, - "src/main/runtime/structured-worker-hook-attestation.test.ts": 1582, - "src/main/runtime/structured-worker-identity.test.ts": 263, - "src/main/runtime/structured-worker-mail-routing.test.ts": 1584, - "src/main/runtime/structured-worker-takeover-pane-key.test.ts": 3936, - "src/main/runtime/structured-worker-terminal-read.test.ts": 1912, - "src/main/runtime/structured-worker-terminal-refusal.test.ts": 170, - "src/main/runtime/terminal-ansi-pending-retention.test.ts": 524, - "src/main/runtime/terminal-chat-owner-persistence.test.ts": 4208, - "src/main/runtime/terminal-close-observed-exit.test.ts": 3596, - "src/main/runtime/terminal-close-slow-agent-exit.test.ts": 5694, - "src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 64, - "src/main/runtime/terminal-identity-probe.test.ts": 31, - "src/main/runtime/terminal-interactive-wait-visibility.test.ts": 9863, - "src/main/runtime/terminal-leaf-tab-resolution.test.ts": 70, - "src/main/runtime/terminal-list-execution-host-scope.test.ts": 6496, - "src/main/runtime/terminal-list-payload-size.test.ts": 3401, - "src/main/runtime/terminal-list-pending-pty-registration.test.ts": 3228, - "src/main/runtime/terminal-list-stale-leaf-liveness.test.ts": 3332, - "src/main/runtime/terminal-list-surface-lost-orphan.test.ts": 3567, - "src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts": 3510, - "src/main/runtime/terminal-model-query-authority.test.ts": 64, - "src/main/runtime/terminal-orphan-owner.test.ts": 28, + "src/main/runtime/repo-worktree-row-resolution.test.ts": 231, + "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts": 724, + "src/main/runtime/ripgrep-filename-identity.test.ts": 68, + "src/main/runtime/rpc/core-typed-method-contract.test.ts": 257, + "src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts": 482, + "src/main/runtime/rpc/dispatcher-computer-errors.test.ts": 559, + "src/main/runtime/rpc/dispatcher-feature-interactions.test.ts": 601, + "src/main/runtime/rpc/dispatcher-request-parsing.test.ts": 296, + "src/main/runtime/rpc/dispatcher-transport-parity.test.ts": 5920, + "src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts": 300, + "src/main/runtime/rpc/e2ee-channel-v2.test.ts": 412, + "src/main/runtime/rpc/e2ee-channel.test.ts": 659, + "src/main/runtime/rpc/e2ee-crypto.test.ts": 139, + "src/main/runtime/rpc/e2ee-integration.test.ts": 357, + "src/main/runtime/rpc/errors.test.ts": 357, + "src/main/runtime/rpc/methods/accounts.test.ts": 248, + "src/main/runtime/rpc/methods/agent-hooks.test.ts": 233, + "src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts": 1188, + "src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts": 1753, + "src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts": 4476, + "src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts": 1776, + "src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts": 2435, + "src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts": 2058, + "src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts": 2073, + "src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts": 1280, + "src/main/runtime/rpc/methods/agent-launch-replay.test.ts": 1990, + "src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts": 5221, + "src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts": 2077, + "src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts": 245, + "src/main/runtime/rpc/methods/agent-launch-terminal-identity.test.ts": 1355, + "src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts": 1325, + "src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts": 385, + "src/main/runtime/rpc/methods/agent-launch.test.ts": 1715, + "src/main/runtime/rpc/methods/agent-session.test.ts": 699, + "src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts": 4568, + "src/main/runtime/rpc/methods/ai-vault-search.test.ts": 4950, + "src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts": 568, + "src/main/runtime/rpc/methods/ai-vault.test.ts": 5161, + "src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts": 4562, + "src/main/runtime/rpc/methods/artifacts.test.ts": 371, + "src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts": 533, + "src/main/runtime/rpc/methods/automations.test.ts": 607, + "src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts": 690, + "src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts": 684, + "src/main/runtime/rpc/methods/browser-client-host.test.ts": 609, + "src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts": 637, + "src/main/runtime/rpc/methods/browser-identity-rpc.test.ts": 562, + "src/main/runtime/rpc/methods/browser-network-tunnel.test.ts": 798, + "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts": 235, + "src/main/runtime/rpc/methods/browser.test.ts": 704, + "src/main/runtime/rpc/methods/client-events.test.ts": 3207, + "src/main/runtime/rpc/methods/client-native-chat-settings.test.ts": 654, + "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": 675, + "src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts": 659, + "src/main/runtime/rpc/methods/client-ui.test.ts": 661, + "src/main/runtime/rpc/methods/clipboard-image-upload-decoding.test.ts": 680, + "src/main/runtime/rpc/methods/clipboard.test.ts": 627, + "src/main/runtime/rpc/methods/computer-actions.test.ts": 326, + "src/main/runtime/rpc/methods/computer.test.ts": 296, + "src/main/runtime/rpc/methods/diagnostics.test.ts": 471, + "src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts": 57, + "src/main/runtime/rpc/methods/files-base64-padding.test.ts": 626, + "src/main/runtime/rpc/methods/files-doc-preview.test.ts": 568, + "src/main/runtime/rpc/methods/files-list-all-page-size.test.ts": 647, + "src/main/runtime/rpc/methods/files-open-navigation.test.ts": 597, + "src/main/runtime/rpc/methods/files-path-search.test.ts": 624, + "src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts": 547, + "src/main/runtime/rpc/methods/files-read-dir-remote-budget.test.ts": 4567, + "src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts": 568, + "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts": 672, + "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts": 565, + "src/main/runtime/rpc/methods/files.test.ts": 815, + "src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts": 1484, + "src/main/runtime/rpc/methods/git.test.ts": 596, + "src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts": 538, + "src/main/runtime/rpc/methods/github.test.ts": 619, + "src/main/runtime/rpc/methods/gitlab.test.ts": 600, + "src/main/runtime/rpc/methods/host-capabilities.test.ts": 508, + "src/main/runtime/rpc/methods/hosted-review.test.ts": 1004, + "src/main/runtime/rpc/methods/jira.test.ts": 584, + "src/main/runtime/rpc/methods/linear-agent-access.test.ts": 4650, + "src/main/runtime/rpc/methods/linear-agent-project-access.test.ts": 5785, + "src/main/runtime/rpc/methods/linear.test.ts": 555, + "src/main/runtime/rpc/methods/managed-server.test.ts": 3128, + "src/main/runtime/rpc/methods/mobile-web-bundle-range.test.ts": 3197, + "src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts": 3452, + "src/main/runtime/rpc/methods/mobile-web-bundle-window-refusals.test.ts": 3539, + "src/main/runtime/rpc/methods/mobile-web-bundle.test.ts": 2747, + "src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts": 699, + "src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts": 2650, + "src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts": 69, + "src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts": 1634, + "src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts": 212, + "src/main/runtime/rpc/methods/native-chat.test.ts": 293, + "src/main/runtime/rpc/methods/notification-preferences.test.ts": 330, + "src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts": 297, + "src/main/runtime/rpc/methods/orcad-migration.test.ts": 3207, + "src/main/runtime/rpc/methods/orcad-terminal-census.test.ts": 214, + "src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts": 6710, + "src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts": 3849, + "src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts": 395, + "src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts": 5166, + "src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts": 462, + "src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts": 1247, + "src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts": 5434, + "src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts": 5547, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts": 5253, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts": 1089, + "src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts": 1242, + "src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts": 920, + "src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts": 1109, + "src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts": 5257, + "src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.test.ts": 54, + "src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.test.ts": 580, + "src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts": 5648, + "src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts": 5387, + "src/main/runtime/rpc/methods/orchestration/federation/federated-transport-safety.test.ts": 4379, + "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.test.ts": 1057, + "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts": 5244, + "src/main/runtime/rpc/methods/orchestration/federation/federation-agent-launch.test.ts": 5782, + "src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts": 6612, + "src/main/runtime/rpc/methods/orchestration/federation/federation-effects.test.ts": 49, + "src/main/runtime/rpc/methods/orchestration/federation/federation-folder-placement.test.ts": 5952, + "src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts": 5779, + "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts": 10158, + "src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts": 5824, + "src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts": 6850, + "src/main/runtime/rpc/methods/orchestration/federation/federation-setup.test.ts": 5357, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-prompt-budget.test.ts": 5348, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts": 230, + "src/main/runtime/rpc/methods/orchestration/federation/federation-worker-brief-agent-in-front.test.ts": 5354, + "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts": 6454, + "src/main/runtime/rpc/methods/orchestration/gates/gate-run-authorization.test.ts": 6061, + "src/main/runtime/rpc/methods/orchestration/gates/gates.test.ts": 4295, + "src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts": 4846, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts": 5619, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts": 5245, + "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts": 4797, + "src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts": 4757, + "src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-history.test.ts": 4560, + "src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-interleavings.test.ts": 4411, + "src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts": 4829, + "src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts": 4949, + "src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts": 4642, + "src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts": 4314, + "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts": 4897, + "src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts": 5370, + "src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts": 223, + "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts": 4878, + "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts": 4979, + "src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts": 6689, + "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts": 5051, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts": 3341, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts": 3237, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts": 5925, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts": 5791, + "src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts": 332, + "src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts": 5697, + "src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts": 289, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts": 5550, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts": 5064, + "src/main/runtime/rpc/methods/orchestration/worker/worker-observation.test.ts": 492, + "src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts": 732, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts": 4946, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts": 4973, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-liveness-verdict.test.ts": 829, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts": 4843, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts": 4849, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts": 5546, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts": 4885, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts": 5097, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts": 1020, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts": 48, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts": 1191, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-outcome-classification.test.ts": 418, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts": 99, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts": 6316, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts": 350, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts": 6240, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-capability.test.ts": 482, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-exit-race.test.ts": 4286, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts": 5777, + "src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-custody-at-creation.test.ts": 5458, + "src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts": 5092, + "src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts": 5732, + "src/main/runtime/rpc/methods/paired-caller-host-id.test.ts": 65, + "src/main/runtime/rpc/methods/pairing.test.ts": 4533, + "src/main/runtime/rpc/methods/plugins.test.ts": 411, + "src/main/runtime/rpc/methods/preflight.test.ts": 520, + "src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts": 256, + "src/main/runtime/rpc/methods/repo-badge-color.test.ts": 605, + "src/main/runtime/rpc/methods/repo.test.ts": 4674, + "src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts": 565, + "src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts": 615, + "src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts": 88, + "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts": 643, + "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts": 66, + "src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts": 69, + "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts": 3462, + "src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts": 4327, + "src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts": 4892, + "src/main/runtime/rpc/methods/session-tabs-pane-layout-intent.test.ts": 604, + "src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts": 578, + "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts": 255, + "src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts": 540, + "src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts": 559, + "src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts": 639, + "src/main/runtime/rpc/methods/session-tabs.test.ts": 676, + "src/main/runtime/rpc/methods/skills.test.ts": 341, + "src/main/runtime/rpc/methods/speech.test.ts": 550, + "src/main/runtime/rpc/methods/ssh.test.ts": 558, + "src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts": 4652, + "src/main/runtime/rpc/methods/status.test.ts": 4704, + "src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts": 3034, + "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts": 5689, + "src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts": 1000, + "src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts": 6678, + "src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts": 2917, + "src/main/runtime/rpc/methods/structured-agent-session-attention-ack.test.ts": 3201, + "src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts": 3064, + "src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.test.ts": 3264, + "src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts": 1688, + "src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts": 290, + "src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts": 5963, + "src/main/runtime/rpc/methods/structured-agent-session-optional-model-capability.test.ts": 2875, + "src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts": 3814, + "src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts": 2971, + "src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 89, + "src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 862, + "src/main/runtime/rpc/methods/structured-agent-session-queued-edit.test.ts": 3153, + "src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts": 2947, + "src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts": 3237, + "src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts": 3111, + "src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts": 3764, + "src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts": 2815, + "src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.test.ts": 2944, + "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts": 3068, + "src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts": 5112, + "src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts": 3030, + "src/main/runtime/rpc/methods/structured-agent-session.test.ts": 3424, + "src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts": 6245, + "src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts": 447, + "src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts": 5290, + "src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts": 4358, + "src/main/runtime/rpc/methods/task-provider-identity.test.ts": 247, + "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts": 312, + "src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts": 853, + "src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts": 378, + "src/main/runtime/rpc/methods/terminal-navigation.test.ts": 830, + "src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts": 340, + "src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts": 83, + "src/main/runtime/rpc/methods/terminal/terminal-input-sequence-ledger.test.ts": 82, + "src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts": 266, + "src/main/runtime/rpc/methods/terminal/terminal-output-batcher.test.ts": 58, + "src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts": 190, + "src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts": 51, + "src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.test.ts": 122, + "src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts": 583, + "src/main/runtime/rpc/methods/updater.test.ts": 275, + "src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts": 304, + "src/main/runtime/rpc/methods/workspace-layout.test.ts": 3003, + "src/main/runtime/rpc/methods/workspace-ports.test.ts": 559, + "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 1589, + "src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts": 1496, + "src/main/runtime/rpc/methods/worktree-create-args.test.ts": 351, + "src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts": 1520, + "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 1623, + "src/main/runtime/rpc/methods/worktree-create-startup-agent.test.ts": 1540, + "src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts": 1481, + "src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts": 1296, + "src/main/runtime/rpc/methods/worktree-retired-names.test.ts": 1517, + "src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts": 1604, + "src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts": 1546, + "src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts": 1498, + "src/main/runtime/rpc/methods/worktree-schemas.test.ts": 356, + "src/main/runtime/rpc/methods/worktree.test.ts": 1713, + "src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts": 835, + "src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts": 72, + "src/main/runtime/rpc/mobile-e2ee-outbound-memory-budget.test.ts": 52, + "src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts": 102, + "src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts": 103, + "src/main/runtime/rpc/mobile-socket-wiring.test.ts": 402, + "src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts": 11255, + "src/main/runtime/rpc/orchestration-caller-show.test.ts": 5152, + "src/main/runtime/rpc/orchestration-check-wait-session.test.ts": 5368, + "src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts": 6014, + "src/main/runtime/rpc/orchestration-contract-fence.test.ts": 5967, + "src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts": 5673, + "src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts": 5854, + "src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts": 6448, + "src/main/runtime/rpc/orchestration-legacy-fence-jurisdiction.test.ts": 5939, + "src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts": 5215, + "src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts": 5130, + "src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts": 5254, + "src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts": 4232, + "src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts": 5733, + "src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts": 5702, + "src/main/runtime/rpc/orchestration-mutation-executor.test.ts": 5127, + "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts": 5979, + "src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts": 5437, + "src/main/runtime/rpc/orchestration-mutation-request-show.test.ts": 5333, + "src/main/runtime/rpc/orchestration-party-addressing.test.ts": 5971, + "src/main/runtime/rpc/orchestration-party-agreement.test.ts": 5225, + "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts": 5613, + "src/main/runtime/rpc/orchestration-session-caller.test.ts": 5634, + "src/main/runtime/rpc/orchestration-session-coordinator.test.ts": 6501, + "src/main/runtime/rpc/orchestration-session-recipient.test.ts": 5755, + "src/main/runtime/rpc/orchestration-session-run-binding.test.ts": 5278, + "src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts": 5900, + "src/main/runtime/rpc/relay-transport.test.ts": 193, + "src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts": 67, + "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts": 82, + "src/main/runtime/rpc/rpc-caller-scope.test.ts": 5768, + "src/main/runtime/rpc/runtime-client-capabilities.test.ts": 40, + "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts": 565, + "src/main/runtime/rpc/runtime-close-attribution.test.ts": 937, + "src/main/runtime/rpc/runtime-source-admission.test.ts": 4337, + "src/main/runtime/rpc/schemas.test.ts": 1500, + "src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts": 5627, + "src/main/runtime/rpc/static-web-client-handler-stream.test.ts": 1767, + "src/main/runtime/rpc/streaming.test.ts": 690, + "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 611, + "src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 612, + "src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 706, + "src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 861, + "src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts": 531, + "src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 1151, + "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery-history.test.ts": 703, + "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts": 2045, + "src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts": 1317, + "src/main/runtime/rpc/terminal-multiplex-end-verdict.test.ts": 3540, + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts": 633, + "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts": 90, + "src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts": 945, + "src/main/runtime/rpc/terminal-multiplex-input-sequence-replay.test.ts": 1902, + "src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts": 1429, + "src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts": 1047, + "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts": 1137, + "src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts": 641, + "src/main/runtime/rpc/terminal-multiplex-round-robin.test.ts": 49, + "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts": 1278, + "src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts": 4066, + "src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts": 790, + "src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts": 4307, + "src/main/runtime/rpc/terminal-output-batching.test.ts": 965, + "src/main/runtime/rpc/terminal-output-frame-chunks.test.ts": 66, + "src/main/runtime/rpc/terminal-output-frame-source-ranges.test.ts": 54, + "src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts": 6663, + "src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts": 807, + "src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts": 1018, + "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts": 543, + "src/main/runtime/rpc/terminal-send.test.ts": 832, + "src/main/runtime/rpc/terminal-source-range-ledger.test.ts": 69, + "src/main/runtime/rpc/terminal-stream-byte-length.test.ts": 450, + "src/main/runtime/rpc/terminal-subscribe-admission.test.ts": 5270, + "src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts": 685, + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts": 1142, + "src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts": 734, + "src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts": 844, + "src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts": 631, + "src/main/runtime/rpc/terminal-subscribe-ownership.test.ts": 1030, + "src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts": 792, + "src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts": 4313, + "src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts": 674, + "src/main/runtime/rpc/unix-socket-transport.test.ts": 86, + "src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts": 45, + "src/main/runtime/rpc/worktree-catalog-snapshot.test.ts": 64, + "src/main/runtime/rpc/worktree-removal-marker-projection.test.ts": 289, + "src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts": 118, + "src/main/runtime/rpc/ws-fallback-port-store.test.ts": 51, + "src/main/runtime/rpc/ws-transport-accept-order.test.ts": 78, + "src/main/runtime/rpc/ws-transport-static-web.test.ts": 99, + "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts": 691, + "src/main/runtime/rpc/ws-transport.test.ts": 1073, + "src/main/runtime/run-terminal-shell-alone.node-pty.test.ts": 2371, + "src/main/runtime/run-terminal-shell-alone.test.ts": 114, + "src/main/runtime/runtime-agent-row-main-agent-projection.test.ts": 94, + "src/main/runtime/runtime-binary-message-router.test.ts": 49, + "src/main/runtime/runtime-browser-client-automation.test.ts": 301, + "src/main/runtime/runtime-browser-client-page-adoption.test.ts": 275, + "src/main/runtime/runtime-browser-client-page-creation.test.ts": 254, + "src/main/runtime/runtime-browser-client-page-recovery.test.ts": 302, + "src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts": 237, + "src/main/runtime/runtime-browser-network-execution-host.test.ts": 45, + "src/main/runtime/runtime-browser-page-registry.test.ts": 55, + "src/main/runtime/runtime-client-settings-linear-team-projection.test.ts": 776, + "src/main/runtime/runtime-client-settings-minimax-projection.test.ts": 568, + "src/main/runtime/runtime-client-settings-source-control-projection.test.ts": 645, + "src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts": 605, + "src/main/runtime/runtime-created-worktree-terminal-target.test.ts": 4338, + "src/main/runtime/runtime-extraction-regressions.test.ts": 4478, + "src/main/runtime/runtime-failed-local-worktree-removal.test.ts": 723, + "src/main/runtime/runtime-file-listing-producer-budgets.test.ts": 528, + "src/main/runtime/runtime-file-target-execution-host.test.ts": 4300, + "src/main/runtime/runtime-file-upload-decode.test.ts": 421, + "src/main/runtime/runtime-folder-workspace.test.ts": 65, + "src/main/runtime/runtime-folder-worktree-create.test.ts": 132, + "src/main/runtime/runtime-git-api-contract.test.ts": 982, + "src/main/runtime/runtime-git-branch-compare-admission.test.ts": 525, + "src/main/runtime/runtime-git-command-target.test.ts": 630, + "src/main/runtime/runtime-git-conflict-operation-routing.test.ts": 561, + "src/main/runtime/runtime-git-execution-host-ownership.test.ts": 959, + "src/main/runtime/runtime-git-generation-admission.test.ts": 551, + "src/main/runtime/runtime-git-stage-worktree-scope.test.ts": 959, + "src/main/runtime/runtime-git-status-admission.test.ts": 1309, + "src/main/runtime/runtime-git-sync-commands.test.ts": 995, + "src/main/runtime/runtime-git-target-execution-host.test.ts": 5048, + "src/main/runtime/runtime-graph-reload-lifecycle.test.ts": 51, + "src/main/runtime/runtime-hook-agent-row-selection.test.ts": 99, + "src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts": 1473, + "src/main/runtime/runtime-legacy-worker-missing-workspace-recovery.test.ts": 81, + "src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts": 150, + "src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts": 399, + "src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts": 96, + "src/main/runtime/runtime-linear-read-commands.test.ts": 161, + "src/main/runtime/runtime-local-create-rearm-ordering.test.ts": 4256, + "src/main/runtime/runtime-local-worktree-create.test.ts": 588, + "src/main/runtime/runtime-local-worktree-materialization.test.ts": 130, + "src/main/runtime/runtime-local-worktree-setup.test.ts": 373, + "src/main/runtime/runtime-local-worktree-terminal-startup.test.ts": 81, + "src/main/runtime/runtime-machine-name.test.ts": 78, + "src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 1804, + "src/main/runtime/runtime-managed-worktree-metadata.test.ts": 94, + "src/main/runtime/runtime-managed-worktree-queries.test.ts": 543, + "src/main/runtime/runtime-metadata-ownership-watch.test.ts": 116, + "src/main/runtime/runtime-metadata.test.ts": 380, + "src/main/runtime/runtime-mobile-agent-status-builder.test.ts": 91, + "src/main/runtime/runtime-mobile-file-path-search-expiry.test.ts": 223, + "src/main/runtime/runtime-mobile-file-path-search.test.ts": 75, + "src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts": 80, + "src/main/runtime/runtime-orchestration-sender-names.test.ts": 948, + "src/main/runtime/runtime-owned-terminal-publication-lineage.test.ts": 5659, + "src/main/runtime/runtime-pairing-desktop-control-grant.test.ts": 5025, + "src/main/runtime/runtime-project-group-controller-folder-create.test.ts": 57, + "src/main/runtime/runtime-project-group-controller-folder-delete.test.ts": 56, + "src/main/runtime/runtime-project-host-setup-controller.test.ts": 410, + "src/main/runtime/runtime-reference-agents.test.ts": 274, + "src/main/runtime/runtime-reference-catalog.test.ts": 97, + "src/main/runtime/runtime-reference-find.test.ts": 264, + "src/main/runtime/runtime-reference-integration.test.ts": 4869, + "src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 506, + "src/main/runtime/runtime-remote-managed-worktree-create.test.ts": 84, + "src/main/runtime/runtime-remove-project-host-scope.test.ts": 5466, + "src/main/runtime/runtime-repository-issue-command.test.ts": 769, + "src/main/runtime/runtime-repository-ref-queries.test.ts": 196, + "src/main/runtime/runtime-resolved-worktree-cache.test.ts": 53, + "src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 5730, + "src/main/runtime/runtime-rpc-client-activity.test.ts": 5083, + "src/main/runtime/runtime-rpc-device-revocation.test.ts": 5464, + "src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts": 5309, + "src/main/runtime/runtime-rpc-long-poll-transport.test.ts": 10349, + "src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts": 5255, + "src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts": 3159, + "src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts": 5792, + "src/main/runtime/runtime-rpc-mobile-unknown-method-scope-refusal.test.ts": 3140, + "src/main/runtime/runtime-rpc-orchestration-db-migration.test.ts": 625, + "src/main/runtime/runtime-rpc-pairing-mode-persistence.test.ts": 5329, + "src/main/runtime/runtime-rpc-pairing-offer.test.ts": 4839, + "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 6365, + "src/main/runtime/runtime-rpc-relay-provider-install.test.ts": 5568, + "src/main/runtime/runtime-rpc-request-authorization.test.ts": 6177, + "src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts": 5067, + "src/main/runtime/runtime-rpc-startup-failure.test.ts": 93, + "src/main/runtime/runtime-rpc-terminal-list.test.ts": 5576, + "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 5192, + "src/main/runtime/runtime-rpc-websocket-fallback-port.test.ts": 5232, + "src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts": 7485, + "src/main/runtime/runtime-rpc-websocket-reply-size-limit.test.ts": 5190, + "src/main/runtime/runtime-rpc-worktree-queries.test.ts": 5245, + "src/main/runtime/runtime-search-line-fragments.test.ts": 572, + "src/main/runtime/runtime-server-git-availability.test.ts": 44, + "src/main/runtime/runtime-session-search-settings.test.ts": 236, + "src/main/runtime/runtime-skill-install-authority.test.ts": 102, + "src/main/runtime/runtime-skill-install-commands.test.ts": 673, + "src/main/runtime/runtime-skill-install-queries.test.ts": 752, + "src/main/runtime/runtime-socket-sweep.test.ts": 50, + "src/main/runtime/runtime-subscription-registry.test.ts": 138, + "src/main/runtime/runtime-terminal-client-use.test.ts": 4273, + "src/main/runtime/runtime-terminal-idle-polls.test.ts": 404, + "src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts": 58, + "src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts": 77, + "src/main/runtime/runtime-terminal-writer.test.ts": 85, + "src/main/runtime/runtime-unregistered-worktree-removal-host-home.test.ts": 1143, + "src/main/runtime/runtime-worktree-agent-rows-structured.test.ts": 650, + "src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts": 1049, + "src/main/runtime/runtime-worktree-agent-sources.test.ts": 62, + "src/main/runtime/runtime-worktree-agent-startup.test.ts": 451, + "src/main/runtime/runtime-worktree-ps-summaries.test.ts": 93, + "src/main/runtime/runtime-worktree-ps-unverifiable-terminals.test.ts": 45, + "src/main/runtime/runtime-worktree-selection.test.ts": 62, + "src/main/runtime/runtime-worktree-startup-readiness.test.ts": 140, + "src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts": 1126, + "src/main/runtime/runtime-worktree-terminal-provisioning.test.ts": 67, + "src/main/runtime/saved-structured-agent-session-restoration.test.ts": 40, + "src/main/runtime/selected-review-branch.test.ts": 61, + "src/main/runtime/session-tabs-empty-worktree-publication.test.ts": 4763, + "src/main/runtime/session-tabs-inventory-publication.test.ts": 4384, + "src/main/runtime/settled-worker-process-replacement.test.ts": 5611, + "src/main/runtime/stale-title-completion-recovery.test.ts": 5144, + "src/main/runtime/stale-title-display-surfaces.test.ts": 5236, + "src/main/runtime/structured-agent-account-kind.test.ts": 58, + "src/main/runtime/structured-agent-create-support-opencode-rig.test.ts": 981, + "src/main/runtime/structured-agent-launch-args-wiring.test.ts": 1637, + "src/main/runtime/structured-agent-launch-support-probe-omp.test.ts": 978, + "src/main/runtime/structured-agent-launch-support-probe.test.ts": 1015, + "src/main/runtime/structured-agent-launch-support.test.ts": 958, + "src/main/runtime/structured-agent-model-catalog-discovery.test.ts": 987, + "src/main/runtime/structured-agent-model-catalog-first-frame.test.ts": 1196, + "src/main/runtime/structured-agent-runtime-registrations-acp.test.ts": 996, + "src/main/runtime/structured-agent-session-attachment-wiring.test.ts": 257, + "src/main/runtime/structured-agent-session-close.test.ts": 357, + "src/main/runtime/structured-agent-session-codex-compact-exit.test.ts": 3778, + "src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts": 2081, + "src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts": 9062, + "src/main/runtime/structured-agent-session-codex-settlement-order.test.ts": 1766, + "src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts": 12290, + "src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts": 5239, + "src/main/runtime/structured-agent-session-failure-logging.test.ts": 1791, + "src/main/runtime/structured-agent-session-held-signal.test.ts": 1930, + "src/main/runtime/structured-agent-session-host-refusal.test.ts": 5070, + "src/main/runtime/structured-agent-session-install-failure.test.ts": 1569, + "src/main/runtime/structured-agent-session-integration-replay.test.ts": 4482, + "src/main/runtime/structured-agent-session-integration.test.ts": 6902, + "src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts": 3540, + "src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts": 205, + "src/main/runtime/structured-agent-session-mobile-attention.test.ts": 212, + "src/main/runtime/structured-agent-session-mobile-restore.test.ts": 1047, + "src/main/runtime/structured-agent-session-mobile-retirement.test.ts": 315, + "src/main/runtime/structured-agent-session-paired-caller.test.ts": 3386, + "src/main/runtime/structured-agent-session-rollback-compatibility.test.ts": 740, + "src/main/runtime/structured-agent-session-runtime-exit.test.ts": 1929, + "src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts": 1605, + "src/main/runtime/structured-agent-session-runtime-provider-started.test.ts": 2396, + "src/main/runtime/structured-agent-session-runtime-teardown.test.ts": 253, + "src/main/runtime/structured-agent-session-runtime.test.ts": 2060, + "src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts": 1655, + "src/main/runtime/structured-agent-session-startup-reconcile.test.ts": 4757, + "src/main/runtime/structured-agent-session-startup-tab-restore.test.ts": 5325, + "src/main/runtime/structured-agent-session-support-probe.test.ts": 4686, + "src/main/runtime/structured-agent-session-tab-close-cause.test.ts": 51, + "src/main/runtime/structured-agent-shell-environment.test.ts": 91, + "src/main/runtime/structured-agent-stored-definitions.test.ts": 334, + "src/main/runtime/structured-chat-coordinator-mail-queue.test.ts": 7105, + "src/main/runtime/structured-chat-coordinator-mail.test.ts": 13367, + "src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts": 5228, + "src/main/runtime/structured-chat-dispatch-worker-mail.test.ts": 6477, + "src/main/runtime/structured-chat-dispatch-worker.test.ts": 5004, + "src/main/runtime/structured-chat-naming-runtime.test.ts": 440, + "src/main/runtime/structured-chat-worker-start.test.ts": 4438, + "src/main/runtime/structured-child-records-switch.test.ts": 2097, + "src/main/runtime/structured-claude-attachment-grant.test.ts": 1678, + "src/main/runtime/structured-claude-auth-policy-wiring.test.ts": 1703, + "src/main/runtime/structured-claude-pending-rewind.test.ts": 1486, + "src/main/runtime/structured-claude-thinking-display-refusal.test.ts": 865, + "src/main/runtime/structured-codex-child-work-runtime.test.ts": 1600, + "src/main/runtime/structured-conversation-tab-title.test.ts": 38, + "src/main/runtime/structured-foreground-command-strip.test.ts": 2430, + "src/main/runtime/structured-session-child-identity-env.test.ts": 312, + "src/main/runtime/structured-session-cli-login-shell.test.ts": 334, + "src/main/runtime/structured-session-mail-redrive-wiring.test.ts": 4818, + "src/main/runtime/structured-session-worktree-teardown.test.ts": 452, + "src/main/runtime/structured-worker-agent-presence.test.ts": 345, + "src/main/runtime/structured-worker-at-rest.test.ts": 1193, + "src/main/runtime/structured-worker-authority.test.ts": 332, + "src/main/runtime/structured-worker-cleared-session.test.ts": 5252, + "src/main/runtime/structured-worker-hook-attestation.test.ts": 3190, + "src/main/runtime/structured-worker-identity.test.ts": 433, + "src/main/runtime/structured-worker-mail-routing.test.ts": 3023, + "src/main/runtime/structured-worker-takeover-pane-key.test.ts": 4488, + "src/main/runtime/structured-worker-terminal-read.test.ts": 2993, + "src/main/runtime/structured-worker-terminal-refusal.test.ts": 357, + "src/main/runtime/terminal-ansi-pending-retention.test.ts": 405, + "src/main/runtime/terminal-chat-owner-persistence.test.ts": 6950, + "src/main/runtime/terminal-close-observed-exit.test.ts": 4756, + "src/main/runtime/terminal-close-slow-agent-exit.test.ts": 6616, + "src/main/runtime/terminal-command-paint.test.ts": 56, + "src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 66, + "src/main/runtime/terminal-foreground-group.test.ts": 78, + "src/main/runtime/terminal-identity-probe.test.ts": 50, + "src/main/runtime/terminal-intentional-stop-exit.test.ts": 4300, + "src/main/runtime/terminal-intentional-stops.test.ts": 42, + "src/main/runtime/terminal-interactive-wait-visibility.test.ts": 5553, + "src/main/runtime/terminal-leaf-tab-resolution.test.ts": 64, + "src/main/runtime/terminal-list-execution-host-scope.test.ts": 6664, + "src/main/runtime/terminal-list-payload-size.test.ts": 4739, + "src/main/runtime/terminal-list-pending-pty-registration.test.ts": 4304, + "src/main/runtime/terminal-list-stale-leaf-liveness.test.ts": 4787, + "src/main/runtime/terminal-list-surface-lost-orphan.test.ts": 4463, + "src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts": 3776, + "src/main/runtime/terminal-model-query-authority.test.ts": 60, + "src/main/runtime/terminal-orphan-owner.test.ts": 41, "src/main/runtime/terminal-orphan-topology.test.ts": 52, - "src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 2841, - "src/main/runtime/terminal-projection.test.ts": 3216, - "src/main/runtime/terminal-pty-exit-waiter.test.ts": 2056, - "src/main/runtime/terminal-query-responder.test.ts": 13500, - "src/main/runtime/terminal-restore-record-seed.test.ts": 3311, - "src/main/runtime/terminal-retirement-async-durability.test.ts": 3860, - "src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 3415, - "src/main/runtime/terminal-retirement-proof-publication.test.ts": 3354, - "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts": 3416, - "src/main/runtime/terminal-shell-override-host-support.test.ts": 42, - "src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 1849, - "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 4855, - "src/main/runtime/terminal-tail-buffer.test.ts": 5683, - "src/main/runtime/terminal-tail-row-retention.test.ts": 314, - "src/main/runtime/terminal-tail-sentinel-index.test.ts": 2880, - "src/main/runtime/terminal-tail-whitespace.test.ts": 80, - "src/main/runtime/terminal-vertical-control-scan.test.ts": 74, - "src/main/runtime/terminal-wait-detection.test.ts": 84, - "src/main/runtime/terminal-wait-name-only-idle.test.ts": 7790, - "src/main/runtime/terminal-wait-results.test.ts": 36, - "src/main/runtime/terminal-wait-tail-window.test.ts": 173, - "src/main/runtime/tui-idle-delivery-and-quiescence.test.ts": 5355, - "src/main/runtime/tui-idle-evidence.test.ts": 75, - "src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts": 20972, - "src/main/runtime/unreadable-secret-store-preservation.win32.test.ts": 38, - "src/main/runtime/wait-blocked-check-state.test.ts": 93, - "src/main/runtime/wait-blocked-keyword-carry-retention.test.ts": 2640, - "src/main/runtime/windows-default-route-interfaces.test.ts": 43, - "src/main/runtime/windows-drive-listing.test.ts": 65, - "src/main/runtime/windows-firewall-remote-scope.test.ts": 53, - "src/main/runtime/windows-mobile-firewall.test.ts": 101, - "src/main/runtime/workspace-session-membership-scaling.test.ts": 47, - "src/main/runtime/workspace-session-worktree-id.test.ts": 124, - "src/main/runtime/worktree-launch-host-repo.test.ts": 42, - "src/main/runtime/worktree-list-host-scope.test.ts": 479, - "src/main/runtime/worktree-path-selector-wsl-posix.test.ts": 2032, - "src/main/runtime/worktree-ps-degraded-repo-scan.test.ts": 2660, - "src/main/runtime/worktree-ps-host-scope.test.ts": 3244, - "src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts": 3632, - "src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts": 2610, - "src/main/runtime/worktree-scan-execution-host-routing.test.ts": 2961, - "src/main/runtime/worktree-teardown-unstopped-pty.test.ts": 436, - "src/main/runtime/worktree-teardown.test.ts": 1806, - "src/main/runtime/worktree-terminal-mutation-lock.test.ts": 104, - "src/main/runtime/zcode-readiness-transcript.test.ts": 3116, - "src/main/serve-update-handoff.app-environment.test.ts": 860, - "src/main/serve-update-handoff.test.ts": 42, - "src/main/server/serve-readiness.test.ts": 67, - "src/main/server/serve-stdout-boundary.test.ts": 39, - "src/main/shell-prompt-readiness-probe.test.ts": 55, - "src/main/shell-startup-identity-scanner.test.ts": 39, - "src/main/shell-startup-output-scanner.test.ts": 67, - "src/main/shell-wrapper-content-address.test.ts": 67, - "src/main/shell-wrapper-generated-file-snapshot.test.ts": 111, - "src/main/sidecar-snapshot-file.test.ts": 52, - "src/main/skills/agent-skill-selection.test.ts": 142, - "src/main/skills/claude-plugin-skill-sources-wsl.test.ts": 85, - "src/main/skills/claude-plugin-skill-sources.test.ts": 42, - "src/main/skills/discovery-filter-sharing.test.ts": 275, - "src/main/skills/discovery-source-filter-order.test.ts": 127, - "src/main/skills/discovery.test.ts": 199, - "src/main/skills/skill-bundle-artifacts.test.ts": 114, - "src/main/skills/skill-bundle-creation.test.ts": 456, - "src/main/skills/skill-bundle-install-service.test.ts": 533, - "src/main/skills/skill-bundle-observability-summary.test.ts": 47, - "src/main/skills/skill-bundle-process-termination-child.test.ts": 248, - "src/main/skills/skill-bundle-process-termination.integration.test.ts": 205, - "src/main/skills/skill-bundle-ssh-relay-service.test.ts": 339, - "src/main/skills/skill-candidate-concurrency.test.ts": 84, - "src/main/skills/skill-client-mediated-transfer-cancellation.test.ts": 105, - "src/main/skills/skill-client-mediated-transfer.test.ts": 197, - "src/main/skills/skill-cloud-direct-upload.test.ts": 182, - "src/main/skills/skill-cloud-grant-installation.test.ts": 172, - "src/main/skills/skill-cloud-install-target.test.ts": 28, - "src/main/skills/skill-cloud-request.test.ts": 152, - "src/main/skills/skill-cloud-service.test.ts": 377, - "src/main/skills/skill-delete/plan.test.ts": 272, - "src/main/skills/skill-delete/recovery.test.ts": 253, - "src/main/skills/skill-delete/roots.test.ts": 71, - "src/main/skills/skill-delete/service.test.ts": 549, - "src/main/skills/skill-delete/staging-visibility.test.ts": 96, - "src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts": 44, - "src/main/skills/skill-discovery-concurrency.test.ts": 194, - "src/main/skills/skill-discovery-order.test.ts": 565, - "src/main/skills/skill-discovery-source-filter.test.ts": 43, - "src/main/skills/skill-discovery-target.test.ts": 116, - "src/main/skills/skill-discovery-wsl-alias-sharing.test.ts": 108, - "src/main/skills/skill-discovery-wsl-bash-filter.test.ts": 3445, - "src/main/skills/skill-discovery-wsl-plugins.test.ts": 87, - "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 115, - "src/main/skills/skill-discovery-wsl.test.ts": 106, - "src/main/skills/skill-freshness-eligibility.test.ts": 92, - "src/main/skills/skill-freshness-inventory-limits.test.ts": 174, - "src/main/skills/skill-freshness-inventory.test.ts": 1854, - "src/main/skills/skill-git-tree-identity.test.ts": 122, - "src/main/skills/skill-install-destinations.test.ts": 75, - "src/main/skills/skill-install-discovery-verification.test.ts": 87, - "src/main/skills/skill-install-lock-release.test.ts": 70, - "src/main/skills/skill-install-lock.test.ts": 398, - "src/main/skills/skill-install-management-service.test.ts": 327, - "src/main/skills/skill-install-provenance.test.ts": 61, - "src/main/skills/skill-install-recovery.test.ts": 479, - "src/main/skills/skill-install-request-service.test.ts": 421, - "src/main/skills/skill-install-service.test.ts": 1067, - "src/main/skills/skill-install-transaction.test.ts": 2836, - "src/main/skills/skill-operation-observability.test.ts": 316, - "src/main/skills/skill-package-creation.test.ts": 427, - "src/main/skills/skill-package-deterministic-gzip.test.ts": 1123, - "src/main/skills/skill-package-download.test.ts": 270, - "src/main/skills/skill-package-identity.test.ts": 196, - "src/main/skills/skill-package-tar.test.ts": 429, - "src/main/skills/skill-placement-alias-repair.test.ts": 192, - "src/main/skills/skill-placement-copy-drift.test.ts": 110, - "src/main/skills/skill-placement-reconciliation.test.ts": 233, - "src/main/skills/skill-placement-transaction.test.ts": 233, - "src/main/skills/skill-plugin-cache-scan.test.ts": 766, - "src/main/skills/skill-process-contention-child.test.ts": 236, - "src/main/skills/skill-process-contention.integration.test.ts": 125, - "src/main/skills/skill-process-termination-child.test.ts": 228, - "src/main/skills/skill-process-termination.integration.test.ts": 169, - "src/main/skills/skill-provider-destinations.test.ts": 39, - "src/main/skills/skill-provider-runtime-roots.test.ts": 38, - "src/main/skills/skill-remote-error-category-parity.test.ts": 228, - "src/main/skills/skill-remote-install-cancellation.test.ts": 28, - "src/main/skills/skill-remote-install-service.test.ts": 220, - "src/main/skills/skill-remove-transaction.test.ts": 494, - "src/main/skills/skill-root-file-walk.test.ts": 87, - "src/main/skills/skill-runtime-capability.test.ts": 59, - "src/main/skills/skill-scan-coalescer.test.ts": 34, - "src/main/skills/skill-share-preparation-service.test.ts": 305, - "src/main/skills/skill-ssh-relay-service.test.ts": 312, - "src/main/skills/skill-transaction-startup-recovery.test.ts": 332, - "src/main/skills/skill-update-convergence.test.ts": 76, - "src/main/skills/skill-update-outcome.test.ts": 306, - "src/main/skills/skill-update-registration.test.ts": 30, - "src/main/skills/skill-update-run.test.ts": 157, - "src/main/skills/skill-upload-process-restart-child.test.ts": 158, - "src/main/skills/skill-upload-process-restart.integration.test.ts": 155, - "src/main/skills/skill-upload-session-admission-regression.test.ts": 199, - "src/main/skills/skill-upload-session-service.test.ts": 240, + "src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 5332, + "src/main/runtime/terminal-projection.test.ts": 54, + "src/main/runtime/terminal-pty-exit-waiter.test.ts": 4535, + "src/main/runtime/terminal-query-responder.test.ts": 15573, + "src/main/runtime/terminal-restore-record-seed.test.ts": 4386, + "src/main/runtime/terminal-retirement-async-durability.test.ts": 5424, + "src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 5172, + "src/main/runtime/terminal-retirement-proof-publication.test.ts": 4203, + "src/main/runtime/terminal-run-facts-input.test.ts": 4384, + "src/main/runtime/terminal-run-facts.test.ts": 55, + "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts": 4372, + "src/main/runtime/terminal-shell-override-host-support.test.ts": 55, + "src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 4589, + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 4236, + "src/main/runtime/terminal-surface-close.test.ts": 203, + "src/main/runtime/terminal-tail-buffer.test.ts": 6881, + "src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts": 1562, + "src/main/runtime/terminal-tail-redraw-scaling.test.ts": 344, + "src/main/runtime/terminal-tail-redraw-spans.test.ts": 251, + "src/main/runtime/terminal-tail-redraw-wall-time.test.ts": 21650, + "src/main/runtime/terminal-tail-row-retention.test.ts": 1528, + "src/main/runtime/terminal-tail-sentinel-index.test.ts": 3542, + "src/main/runtime/terminal-tail-whitespace.test.ts": 276, + "src/main/runtime/terminal-vertical-control-scan.test.ts": 307, + "src/main/runtime/terminal-wait-detection.test.ts": 350, + "src/main/runtime/terminal-wait-name-only-idle.test.ts": 4243, + "src/main/runtime/terminal-wait-results.test.ts": 38, + "src/main/runtime/terminal-wait-tail-window.test.ts": 263, + "src/main/runtime/tui-idle-claude-task-wakeup.test.ts": 282, + "src/main/runtime/tui-idle-command-paint.test.ts": 4068, + "src/main/runtime/tui-idle-delivery-and-quiescence.test.ts": 5085, + "src/main/runtime/tui-idle-evidence-source.test.ts": 3277, + "src/main/runtime/tui-idle-evidence.test.ts": 374, + "src/main/runtime/tui-idle-hook-lane-runtime.test.ts": 7403, + "src/main/runtime/tui-idle-hook-lane.test.ts": 301, + "src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts": 35459, + "src/main/runtime/tui-idle-weak-evidence-seam.test.ts": 3901, + "src/main/runtime/unreadable-secret-store-preservation.win32.test.ts": 54, + "src/main/runtime/wait-blocked-check-state.test.ts": 90, + "src/main/runtime/wait-blocked-keyword-carry-retention.test.ts": 3758, + "src/main/runtime/windows-default-route-interfaces.test.ts": 62, + "src/main/runtime/windows-drive-listing.test.ts": 54, + "src/main/runtime/windows-firewall-remote-scope.test.ts": 81, + "src/main/runtime/windows-mobile-firewall.test.ts": 99, + "src/main/runtime/workspace-layout-round-trip-watch.test.ts": 254, + "src/main/runtime/workspace-layout-stream.test.ts": 257, + "src/main/runtime/workspace-session-membership-scaling.test.ts": 99, + "src/main/runtime/workspace-session-worktree-id.test.ts": 254, + "src/main/runtime/worktree-launch-host-repo.test.ts": 52, + "src/main/runtime/worktree-list-host-scope.test.ts": 1039, + "src/main/runtime/worktree-path-selector-wsl-posix.test.ts": 4715, + "src/main/runtime/worktree-ps-degraded-repo-scan.test.ts": 5316, + "src/main/runtime/worktree-ps-host-scope.test.ts": 4255, + "src/main/runtime/worktree-ps-unverifiable-terminals.test.ts": 4242, + "src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts": 4532, + "src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts": 4765, + "src/main/runtime/worktree-scan-execution-host-routing.test.ts": 4500, + "src/main/runtime/worktree-scan-repeated-path.test.ts": 3815, + "src/main/runtime/worktree-teardown-unstopped-pty.test.ts": 554, + "src/main/runtime/worktree-teardown.test.ts": 863, + "src/main/runtime/worktree-terminal-mutation-lock.test.ts": 57, + "src/main/serve-update-handoff.app-environment.test.ts": 1475, + "src/main/serve-update-handoff.test.ts": 58, + "src/main/server/serve-readiness.test.ts": 69, + "src/main/server/serve-stdout-boundary.test.ts": 47, + "src/main/shell-prompt-readiness-probe.test.ts": 94, + "src/main/shell-startup-identity-scanner.test.ts": 43, + "src/main/shell-startup-output-scanner.test.ts": 84, + "src/main/shell-wrapper-content-address.test.ts": 56, + "src/main/shell-wrapper-generated-file-snapshot.test.ts": 118, + "src/main/sidecar-snapshot-file.test.ts": 59, + "src/main/skills/agent-skill-selection.test.ts": 238, + "src/main/skills/antigravity-skill-discovery.test.ts": 95, + "src/main/skills/claude-plugin-skill-sources-wsl.test.ts": 106, + "src/main/skills/claude-plugin-skill-sources.test.ts": 95, + "src/main/skills/discovery-filter-sharing.test.ts": 247, + "src/main/skills/discovery-source-filter-order.test.ts": 103, + "src/main/skills/discovery.test.ts": 187, + "src/main/skills/skill-bundle-artifacts.test.ts": 285, + "src/main/skills/skill-bundle-creation.test.ts": 636, + "src/main/skills/skill-bundle-install-service.test.ts": 447, + "src/main/skills/skill-bundle-observability-summary.test.ts": 39, + "src/main/skills/skill-bundle-process-termination-child.test.ts": 276, + "src/main/skills/skill-bundle-process-termination.integration.test.ts": 294, + "src/main/skills/skill-bundle-ssh-relay-service.test.ts": 215, + "src/main/skills/skill-candidate-concurrency.test.ts": 67, + "src/main/skills/skill-client-mediated-transfer-cancellation.test.ts": 278, + "src/main/skills/skill-client-mediated-transfer.test.ts": 295, + "src/main/skills/skill-cloud-direct-upload.test.ts": 110, + "src/main/skills/skill-cloud-grant-installation.test.ts": 253, + "src/main/skills/skill-cloud-install-target.test.ts": 54, + "src/main/skills/skill-cloud-request.test.ts": 62, + "src/main/skills/skill-cloud-service.test.ts": 512, + "src/main/skills/skill-delete/plan.test.ts": 217, + "src/main/skills/skill-delete/recovery.test.ts": 266, + "src/main/skills/skill-delete/roots.test.ts": 98, + "src/main/skills/skill-delete/service.test.ts": 497, + "src/main/skills/skill-delete/staging-visibility.test.ts": 124, + "src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts": 98, + "src/main/skills/skill-discovery-concurrency.test.ts": 152, + "src/main/skills/skill-discovery-order.test.ts": 267, + "src/main/skills/skill-discovery-source-filter.test.ts": 41, + "src/main/skills/skill-discovery-target.test.ts": 121, + "src/main/skills/skill-discovery-wsl-alias-sharing.test.ts": 124, + "src/main/skills/skill-discovery-wsl-bash-filter.test.ts": 2071, + "src/main/skills/skill-discovery-wsl-plugins.test.ts": 93, + "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 112, + "src/main/skills/skill-discovery-wsl.test.ts": 111, + "src/main/skills/skill-freshness-eligibility.test.ts": 84, + "src/main/skills/skill-freshness-inventory-limits.test.ts": 254, + "src/main/skills/skill-freshness-inventory.test.ts": 902, + "src/main/skills/skill-git-tree-identity.test.ts": 79, + "src/main/skills/skill-install-destinations.test.ts": 68, + "src/main/skills/skill-install-discovery-verification.test.ts": 129, + "src/main/skills/skill-install-lock-release.test.ts": 48, + "src/main/skills/skill-install-lock.test.ts": 438, + "src/main/skills/skill-install-management-service.test.ts": 319, + "src/main/skills/skill-install-provenance.test.ts": 85, + "src/main/skills/skill-install-recovery.test.ts": 513, + "src/main/skills/skill-install-request-service.test.ts": 714, + "src/main/skills/skill-install-service.test.ts": 1015, + "src/main/skills/skill-install-transaction.test.ts": 1418, + "src/main/skills/skill-operation-observability.test.ts": 330, + "src/main/skills/skill-package-creation.test.ts": 502, + "src/main/skills/skill-package-deterministic-gzip.test.ts": 1040, + "src/main/skills/skill-package-download-response-lifetime.test.ts": 904, + "src/main/skills/skill-package-download.test.ts": 318, + "src/main/skills/skill-package-identity.test.ts": 117, + "src/main/skills/skill-package-tar.test.ts": 422, + "src/main/skills/skill-placement-alias-repair.test.ts": 238, + "src/main/skills/skill-placement-copy-drift.test.ts": 211, + "src/main/skills/skill-placement-reconciliation.test.ts": 280, + "src/main/skills/skill-placement-transaction.test.ts": 322, + "src/main/skills/skill-plugin-cache-scan.test.ts": 325, + "src/main/skills/skill-process-contention-child.test.ts": 259, + "src/main/skills/skill-process-contention.integration.test.ts": 245, + "src/main/skills/skill-process-termination-child.test.ts": 270, + "src/main/skills/skill-process-termination.integration.test.ts": 310, + "src/main/skills/skill-provider-destinations.test.ts": 64, + "src/main/skills/skill-provider-runtime-roots.test.ts": 79, + "src/main/skills/skill-remote-error-category-parity.test.ts": 264, + "src/main/skills/skill-remote-install-cancellation.test.ts": 46, + "src/main/skills/skill-remote-install-service.test.ts": 273, + "src/main/skills/skill-remove-transaction.test.ts": 852, + "src/main/skills/skill-root-file-walk.test.ts": 67, + "src/main/skills/skill-runtime-capability.test.ts": 77, + "src/main/skills/skill-scan-coalescer.test.ts": 62, + "src/main/skills/skill-share-preparation-service.test.ts": 426, + "src/main/skills/skill-ssh-relay-service.test.ts": 349, + "src/main/skills/skill-transaction-startup-recovery.test.ts": 471, + "src/main/skills/skill-update-convergence.test.ts": 73, + "src/main/skills/skill-update-outcome.test.ts": 313, + "src/main/skills/skill-update-registration.test.ts": 51, + "src/main/skills/skill-update-run.test.ts": 154, + "src/main/skills/skill-upload-process-restart-child.test.ts": 222, + "src/main/skills/skill-upload-process-restart.integration.test.ts": 244, + "src/main/skills/skill-upload-session-admission-regression.test.ts": 301, + "src/main/skills/skill-upload-session-service.test.ts": 390, "src/main/skills/skill-windows-rename-contention.integration.test.ts": 43, - "src/main/skills/skill-windows-workspace.integration.test.ts": 180, - "src/main/skills/skill-wsl-delete.integration.test.ts": 55, - "src/main/skills/skill-wsl-install-filesystem.test.ts": 63, - "src/main/skills/skill-wsl-install-transaction.integration.test.ts": 256, - "src/main/skills/skill-wsl-posix-semantics.integration.test.ts": 225, - "src/main/skills/skill-wsl-provider-detection.test.ts": 46, - "src/main/source-control/forge-provider.test.ts": 259, - "src/main/source-control/hosted-review-azure-devops.integration.test.ts": 548, - "src/main/source-control/hosted-review-base-ref-suffix.test.ts": 210, - "src/main/source-control/hosted-review-bitbucket.integration.test.ts": 1294, - "src/main/source-control/hosted-review-branch-cache.test.ts": 669, - "src/main/source-control/hosted-review-creation-eligibility.test.ts": 325, - "src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts": 194, - "src/main/source-control/hosted-review-creation-shared-symlinks.test.ts": 192, - "src/main/source-control/hosted-review-creation.test.ts": 230, - "src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts": 222, - "src/main/source-control/hosted-review-execution-host-routing.test.ts": 101, - "src/main/source-control/hosted-review-gitea.integration.test.ts": 850, - "src/main/source-control/hosted-review.test.ts": 112, - "src/main/source-control/pull-request-linked-issue.test.ts": 54, - "src/main/source-control/repo-default-branch.test.ts": 88, - "src/main/source-control/stacked-hosted-review-creation.test.ts": 44, - "src/main/speech/model-catalog.test.ts": 41, - "src/main/speech/model-manager-download-error.test.ts": 83, - "src/main/speech/model-manager-download-resume.test.ts": 3099, - "src/main/speech/model-manager-progress-callback.test.ts": 64, - "src/main/speech/model-manager-stream-cleanup.test.ts": 77, - "src/main/speech/model-manager-windows-path.test.ts": 242, - "src/main/speech/model-manager.test.ts": 140, - "src/main/speech/openai-api-key-store.test.ts": 53, - "src/main/speech/openai-transcription-client.test.ts": 51, - "src/main/speech/speech-model-deletion.test.ts": 158, - "src/main/speech/speech-model-download-response.test.ts": 62, - "src/main/speech/stt-offline-audio-chunker.test.ts": 155, - "src/main/speech/stt-service.test.ts": 86, - "src/main/speech/stt-worker-model-config.test.ts": 48, - "src/main/speech/stt-worker.test.ts": 38, - "src/main/sqlite/sqlite-read-failure.test.ts": 60, - "src/main/sqlite/sync-database-concurrent-backup.test.ts": 815, - "src/main/sqlite/sync-database-portability.test.ts": 108, - "src/main/sqlite/sync-database.test.ts": 806, - "src/main/ssh-expired-lease-pane-readoption.test.ts": 1542, - "src/main/ssh-reattach-pane-cardinality.test.ts": 1109, - "src/main/ssh/orcad-activation-gate.test.ts": 56, - "src/main/ssh/orcad-activation-record.test.ts": 44, - "src/main/ssh/orcad-artifact-materializer.test.ts": 681, - "src/main/ssh/orcad-deployment-target.test.ts": 195, - "src/main/ssh/orcad-remote-deploy.test.ts": 479, - "src/main/ssh/orcad-remote-gc.test.ts": 161, - "src/main/ssh/orcad-remote-install-termination.test.ts": 201, - "src/main/ssh/orcad-remote-launch.test.ts": 169, - "src/main/ssh/orcad-remote-rollback.test.ts": 254, - "src/main/ssh/orcad-remote-runtime.test.ts": 228, - "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 4806, - "src/main/ssh/orcad-state-snapshot.test.ts": 135, - "src/main/ssh/orcad-update-plan.test.ts": 62, - "src/main/ssh/pinned-runtime-materializer.test.ts": 357, - "src/main/ssh/relay-native-dependency-coverage.test.ts": 504, - "src/main/ssh/relay-protocol-backpressure.test.ts": 103, - "src/main/ssh/relay-protocol.test.ts": 70, - "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 95, - "src/main/ssh/relay-socket-path-limit.test.ts": 194, - "src/main/ssh/remote-install-coexistence.test.ts": 50, - "src/main/ssh/remote-install-gc-termination.test.ts": 195, - "src/main/ssh/remote-install-model.test.ts": 224, - "src/main/ssh/removed-ssh-target-tombstone-retention.test.ts": 59, - "src/main/ssh/sftp-namespace-resolution.test.ts": 94, - "src/main/ssh/sftp-stream-late-error.test.ts": 177, - "src/main/ssh/sftp-upload.test.ts": 175, - "src/main/ssh/ssh-agent-identity-filter.test.ts": 47, - "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts": 98, - "src/main/ssh/ssh-channel-multiplexer-saturation-wedge.test.ts": 73, - "src/main/ssh/ssh-channel-multiplexer-settlement.test.ts": 77, - "src/main/ssh/ssh-channel-multiplexer.test.ts": 63, - "src/main/ssh/ssh-config-alias-claim.test.ts": 74, - "src/main/ssh/ssh-config-host-picker.test.ts": 318, - "src/main/ssh/ssh-config-loader-regression.test.ts": 156, - "src/main/ssh/ssh-config-loader.test.ts": 95, - "src/main/ssh/ssh-config-parser-host-patterns.test.ts": 51, - "src/main/ssh/ssh-config-parser.test.ts": 392, - "src/main/ssh/ssh-config-resolver.test.ts": 55, - "src/main/ssh/ssh-connect-attempt-cancellation.test.ts": 784, - "src/main/ssh/ssh-connection-auth-fallback.test.ts": 232, - "src/main/ssh/ssh-connection-channel-open.test.ts": 877, - "src/main/ssh/ssh-connection-direct-startup.test.ts": 192, - "src/main/ssh/ssh-connection-file-transfer.test.ts": 130, - "src/main/ssh/ssh-connection-generation.test.ts": 62, - "src/main/ssh/ssh-connection-github-probe.test.ts": 123, - "src/main/ssh/ssh-connection-gssapi-fallback.test.ts": 259, - "src/main/ssh/ssh-connection-host-key-store-wiring.test.ts": 462, - "src/main/ssh/ssh-connection-host-key-verification.test.ts": 157, - "src/main/ssh/ssh-connection-manager-registry.test.ts": 163, - "src/main/ssh/ssh-connection-manager.test.ts": 60, - "src/main/ssh/ssh-connection-reconnect-ladder.test.ts": 420, - "src/main/ssh/ssh-connection-sftp-namespace.test.ts": 901, - "src/main/ssh/ssh-connection-sftp-wire.test.ts": 2538, - "src/main/ssh/ssh-connection-store.test.ts": 100, - "src/main/ssh/ssh-connection-system-transport.test.ts": 252, - "src/main/ssh/ssh-connection-utils.test.ts": 213, - "src/main/ssh/ssh-connection.test.ts": 395, - "src/main/ssh/ssh-control-socket.test.ts": 47, - "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 68, - "src/main/ssh/ssh-file-transfer-abort.test.ts": 109, - "src/main/ssh/ssh-filesystem-stream-retention.test.ts": 122, - "src/main/ssh/ssh-g-config-resolution.test.ts": 82, - "src/main/ssh/ssh-git-response-stream-reader.test.ts": 61, - "src/main/ssh/ssh-git-stream-idle-timer.test.ts": 63, - "src/main/ssh/ssh-host-key-decision.test.ts": 60, - "src/main/ssh/ssh-host-key-store.test.ts": 238, - "src/main/ssh/ssh-host-key-verifier.test.ts": 70, - "src/main/ssh/ssh-known-hosts-source.test.ts": 104, - "src/main/ssh/ssh-known-hosts.test.ts": 106, - "src/main/ssh/ssh-multi-factor-authentication.test.ts": 1337, - "src/main/ssh/ssh-multi-key-authentication.test.ts": 161, - "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 77, - "src/main/ssh/ssh-multiplexer-writer-retention.test.ts": 538, - "src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts": 72, - "src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts": 95, - "src/main/ssh/ssh-owner-recovery-retry.test.ts": 69, - "src/main/ssh/ssh-pending-pty-kill-replay.test.ts": 78, - "src/main/ssh/ssh-port-forward.test.ts": 221, - "src/main/ssh/ssh-port-scanner.test.ts": 80, - "src/main/ssh/ssh-posix-command-wrapper.test.ts": 124, - "src/main/ssh/ssh-provider-authority.test.ts": 44, - "src/main/ssh/ssh-proxy-command.test.ts": 148, - "src/main/ssh/ssh-pty-consumer-recovery.test.ts": 61, - "src/main/ssh/ssh-pty-consumer-session.test.ts": 70, - "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts": 34, - "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts": 46, - "src/main/ssh/ssh-reconnect-error-classification.test.ts": 86, - "src/main/ssh/ssh-reconnect-ladder.test.ts": 153, - "src/main/ssh/ssh-relay-build-toolchain.test.ts": 154, - "src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts": 318, - "src/main/ssh/ssh-relay-cross-version-isolation.test.ts": 265, - "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 313, - "src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts": 260, - "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts": 502, - "src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts": 207, - "src/main/ssh/ssh-relay-deploy.test.ts": 690, - "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2168, - "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 99, - "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 116, - "src/main/ssh/ssh-relay-gc-claim-termination.test.ts": 174, - "src/main/ssh/ssh-relay-gc-retry.test.ts": 332, - "src/main/ssh/ssh-relay-incumbent-process.test.ts": 15877, - "src/main/ssh/ssh-relay-install-lock-termination.test.ts": 229, - "src/main/ssh/ssh-relay-install-lock.test.ts": 203, - "src/main/ssh/ssh-relay-install-namespace.test.ts": 186, - "src/main/ssh/ssh-relay-live-connect.test.ts": 214, - "src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts": 1792, - "src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts": 1158, - "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 525, - "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 236, - "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts": 255, - "src/main/ssh/ssh-relay-native-deps-install.test.ts": 14222, - "src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts": 418, - "src/main/ssh/ssh-relay-node-headers.test.ts": 1899, - "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 124, - "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 215, - "src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts": 302, - "src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts": 588, - "src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts": 77, - "src/main/ssh/ssh-relay-opencode-runtime.test.ts": 392, - "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 2554, - "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 2127, - "src/main/ssh/ssh-relay-repair-lock-termination.test.ts": 237, - "src/main/ssh/ssh-relay-reset.test.ts": 133, - "src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts": 521, - "src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts": 131, - "src/main/ssh/ssh-relay-ripgrep-install.test.ts": 389, - "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 115, - "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts": 4019, - "src/main/ssh/ssh-relay-session-data-delivery.test.ts": 2680, - "src/main/ssh/ssh-relay-session-incarnation.test.ts": 2776, - "src/main/ssh/ssh-relay-session-managed-hooks.test.ts": 2737, - "src/main/ssh/ssh-relay-session-model-migration.test.ts": 3018, - "src/main/ssh/ssh-relay-session-orphan-sweep.test.ts": 2684, - "src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts": 2344, - "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts": 3189, - "src/main/ssh/ssh-relay-session-recovery-durability.test.ts": 4088, - "src/main/ssh/ssh-relay-session-recovery-races.test.ts": 3683, - "src/main/ssh/ssh-relay-session-rejected-delivery.test.ts": 3066, - "src/main/ssh/ssh-relay-session-relay-loss.test.ts": 2301, - "src/main/ssh/ssh-relay-session-terminal-error.test.ts": 2096, - "src/main/ssh/ssh-relay-session.test.ts": 10932, - "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts": 475, - "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 197, - "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts": 357, - "src/main/ssh/ssh-relay-upload-stage-commands.test.ts": 27518, - "src/main/ssh/ssh-relay-versioned-install-termination.test.ts": 155, - "src/main/ssh/ssh-relay-versioned-install.test.ts": 549, - "src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts": 4113, - "src/main/ssh/ssh-remote-cli-format.test.ts": 54, - "src/main/ssh/ssh-remote-cli-host-passthrough.test.ts": 200, - "src/main/ssh/ssh-remote-cli-launcher.test.ts": 80, - "src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts": 3331, - "src/main/ssh/ssh-remote-commands.test.ts": 15974, - "src/main/ssh/ssh-remote-linear-activity-output.test.ts": 36, - "src/main/ssh/ssh-remote-linear-cli.test.ts": 4061, - "src/main/ssh/ssh-remote-linear-list-issues.test.ts": 61, - "src/main/ssh/ssh-remote-linear-relation-write.test.ts": 1192, - "src/main/ssh/ssh-remote-linear-save-issue.test.ts": 2067, - "src/main/ssh/ssh-remote-linear-truncation-output.test.ts": 39, - "src/main/ssh/ssh-remote-node-resolution.test.ts": 273, - "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 241, - "src/main/ssh/ssh-remote-orca-cli.test.ts": 3176, - "src/main/ssh/ssh-remote-orchestration-compatibility.test.ts": 3146, - "src/main/ssh/ssh-remote-orchestration-send.test.ts": 29, - "src/main/ssh/ssh-remote-platform-detection.test.ts": 130, - "src/main/ssh/ssh-remote-platform.test.ts": 190, - "src/main/ssh/ssh-remote-powershell.test.ts": 238, - "src/main/ssh/ssh-remote-windows-command-line-limit.test.ts": 222, - "src/main/ssh/ssh-request-outcome-verdict.test.ts": 40, - "src/main/ssh/ssh-security-key-identity.test.ts": 179, - "src/main/ssh/ssh-session-limit-error.test.ts": 27, - "src/main/ssh/ssh-system-fallback.test.ts": 556, - "src/main/ssh/ssh-system-transport.integration.test.ts": 878, - "src/main/ssh/ssh-target-id-migration.test.ts": 41, - "src/main/ssh/ssh-target-readoption.test.ts": 64, - "src/main/ssh/system-ssh-binary.test.ts": 59, - "src/main/ssh/system-ssh-dynamic-forward-process.test.ts": 237, - "src/main/ssh/system-ssh-forward-process.test.ts": 233, - "src/main/ssh/system-ssh-sftp-args.test.ts": 38, + "src/main/skills/skill-windows-workspace.integration.test.ts": 345, + "src/main/skills/skill-wsl-delete.integration.test.ts": 70, + "src/main/skills/skill-wsl-install-filesystem.test.ts": 82, + "src/main/skills/skill-wsl-install-transaction.integration.test.ts": 254, + "src/main/skills/skill-wsl-posix-semantics.integration.test.ts": 326, + "src/main/skills/skill-wsl-provider-detection.test.ts": 84, + "src/main/source-control/forge-provider.test.ts": 418, + "src/main/source-control/hosted-review-azure-devops.integration.test.ts": 842, + "src/main/source-control/hosted-review-base-ref-suffix.test.ts": 428, + "src/main/source-control/hosted-review-bitbucket.integration.test.ts": 2640, + "src/main/source-control/hosted-review-branch-cache.test.ts": 216, + "src/main/source-control/hosted-review-creation-eligibility.test.ts": 488, + "src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts": 507, + "src/main/source-control/hosted-review-creation-shared-symlinks.test.ts": 488, + "src/main/source-control/hosted-review-creation.test.ts": 487, + "src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts": 440, + "src/main/source-control/hosted-review-execution-host-routing.test.ts": 406, + "src/main/source-control/hosted-review-gitea.integration.test.ts": 771, + "src/main/source-control/hosted-review-inflight-invalidation.test.ts": 131, + "src/main/source-control/hosted-review-inflight-lookups.test.ts": 49, + "src/main/source-control/hosted-review.test.ts": 465, + "src/main/source-control/pull-request-linked-issue.test.ts": 63, + "src/main/source-control/repo-default-branch.test.ts": 137, + "src/main/source-control/stacked-hosted-review-creation.test.ts": 51, + "src/main/speech/model-catalog.test.ts": 48, + "src/main/speech/model-manager-download-error.test.ts": 152, + "src/main/speech/model-manager-download-resume.test.ts": 3126, + "src/main/speech/model-manager-progress-callback.test.ts": 65, + "src/main/speech/model-manager-stream-cleanup.test.ts": 124, + "src/main/speech/model-manager-windows-path.test.ts": 170, + "src/main/speech/model-manager.test.ts": 90, + "src/main/speech/openai-api-key-store.test.ts": 82, + "src/main/speech/openai-transcription-client.test.ts": 49, + "src/main/speech/speech-model-deletion.test.ts": 292, + "src/main/speech/speech-model-download-response.test.ts": 49, + "src/main/speech/stt-offline-audio-chunker.test.ts": 222, + "src/main/speech/stt-service.test.ts": 109, + "src/main/speech/stt-worker-model-config.test.ts": 76, + "src/main/speech/stt-worker.test.ts": 49, + "src/main/sqlite/node-sqlite-reader-api.test.ts": 54, + "src/main/sqlite/sqlite-read-failure.test.ts": 67, + "src/main/sqlite/sync-database-concurrent-backup.test.ts": 1201, + "src/main/sqlite/sync-database-portability.test.ts": 114, + "src/main/sqlite/sync-database.test.ts": 895, + "src/main/ssh-expired-lease-pane-readoption.test.ts": 1865, + "src/main/ssh-reattach-pane-cardinality.test.ts": 1839, + "src/main/ssh/managed-server-fence-recheck.test.ts": 459, + "src/main/ssh/orcad-activation-crash-recovery.test.ts": 754, + "src/main/ssh/orcad-activation-fence-generation.test.ts": 645, + "src/main/ssh/orcad-activation-fence-hold.test.ts": 397, + "src/main/ssh/orcad-activation-gate.test.ts": 62, + "src/main/ssh/orcad-activation-outcome-log.test.ts": 55, + "src/main/ssh/orcad-activation-record.test.ts": 254, + "src/main/ssh/orcad-activation-transaction.test.ts": 398, + "src/main/ssh/orcad-artifact-materializer.test.ts": 722, + "src/main/ssh/orcad-decommission-released-peer-recovery.test.ts": 295, + "src/main/ssh/orcad-decommission-transaction.test.ts": 232, + "src/main/ssh/orcad-deployment-target.test.ts": 234, + "src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts": 775, + "src/main/ssh/orcad-exited-own-fence.test.ts": 5738, + "src/main/ssh/orcad-exited-own-install-lock.test.ts": 4492, + "src/main/ssh/orcad-exited-own-lock-windows.test.ts": 350, + "src/main/ssh/orcad-fence-lost-classification.test.ts": 287, + "src/main/ssh/orcad-host-unavailable.test.ts": 325, + "src/main/ssh/orcad-managed-auto-update.test.ts": 319, + "src/main/ssh/orcad-managed-bound-port.test.ts": 386, + "src/main/ssh/orcad-managed-serving.test.ts": 480, + "src/main/ssh/orcad-managed-tunnel-identity-restart.test.ts": 4422, + "src/main/ssh/orcad-managed-tunnel-identity.test.ts": 403, + "src/main/ssh/orcad-managed-tunnel-target.test.ts": 401, + "src/main/ssh/orcad-managed-tunnel-transport.test.ts": 367, + "src/main/ssh/orcad-managed-tunnel.test.ts": 901, + "src/main/ssh/orcad-managed-update-deferrals.test.ts": 37, + "src/main/ssh/orcad-managed-update-on-restore.test.ts": 334, + "src/main/ssh/orcad-managed-version-gc.test.ts": 375, + "src/main/ssh/orcad-managed-wake.test.ts": 467, + "src/main/ssh/orcad-migration-catalog-client-extra-agent-args.test.ts": 339, + "src/main/ssh/orcad-migration-catalog-client.test.ts": 294, + "src/main/ssh/orcad-migration-cutover-coordinator.test.ts": 1549, + "src/main/ssh/orcad-migration-cutover-journal.test.ts": 342, + "src/main/ssh/orcad-migration-delta-move.test.ts": 3105, + "src/main/ssh/orcad-migration-delta-snapshot.test.ts": 1922, + "src/main/ssh/orcad-migration-manifest-export.test.ts": 384, + "src/main/ssh/orcad-migration-refusal-reason.test.ts": 40, + "src/main/ssh/orcad-migration-relay-pty-lister.test.ts": 329, + "src/main/ssh/orcad-migration-scrollback-retention-wiring.test.ts": 59, + "src/main/ssh/orcad-migration-snapshot-coordinator.test.ts": 309, + "src/main/ssh/orcad-migration-snapshot-resume.test.ts": 1220, + "src/main/ssh/orcad-migration-source-fence.test.ts": 1306, + "src/main/ssh/orcad-migration-source-retention.test.ts": 385, + "src/main/ssh/orcad-migration-terminal-gate-host-wide.test.ts": 238, + "src/main/ssh/orcad-migration-terminal-gate.test.ts": 60, + "src/main/ssh/orcad-remote-build-hash.integration.test.ts": 404, + "src/main/ssh/orcad-remote-decommission.test.ts": 387, + "src/main/ssh/orcad-remote-deploy.test.ts": 603, + "src/main/ssh/orcad-remote-gc.test.ts": 468, + "src/main/ssh/orcad-remote-install-termination.test.ts": 495, + "src/main/ssh/orcad-remote-launch-file-modes.test.ts": 421, + "src/main/ssh/orcad-remote-launch.test.ts": 348, + "src/main/ssh/orcad-remote-lifecycle-windows.test.ts": 454, + "src/main/ssh/orcad-remote-log-tail.test.ts": 409, + "src/main/ssh/orcad-remote-node-runtime-promotion-lock.test.ts": 185, + "src/main/ssh/orcad-remote-node-runtime-slow-hash.test.ts": 1212, + "src/main/ssh/orcad-remote-node-runtime-windows.test.ts": 221, + "src/main/ssh/orcad-remote-node-runtime.test.ts": 220, + "src/main/ssh/orcad-remote-primitives.test.ts": 429, + "src/main/ssh/orcad-remote-readiness-wait.test.ts": 1837, + "src/main/ssh/orcad-remote-rollback.test.ts": 570, + "src/main/ssh/orcad-remote-runtime-control.test.ts": 281, + "src/main/ssh/orcad-remote-runtime.test.ts": 680, + "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 10068, + "src/main/ssh/orcad-remote-windows-commands.test.ts": 374, + "src/main/ssh/orcad-remote-windows-decommission.test.ts": 448, + "src/main/ssh/orcad-remote-windows-scripts.test.ts": 3448, + "src/main/ssh/orcad-retained-fence-takeover.test.ts": 194, + "src/main/ssh/orcad-retained-source-lists.test.ts": 325, + "src/main/ssh/orcad-runtime-conversion.test.ts": 2065, + "src/main/ssh/orcad-runtime-decommission.test.ts": 886, + "src/main/ssh/orcad-runtime-deployment.test.ts": 767, + "src/main/ssh/orcad-runtime-forget.test.ts": 558, + "src/main/ssh/orcad-runtime-maintenance.test.ts": 744, + "src/main/ssh/orcad-runtime-target.test.ts": 393, + "src/main/ssh/orcad-ssh-provisioning.test.ts": 279, + "src/main/ssh/orcad-state-mutation-exec.test.ts": 343, + "src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts": 17438, + "src/main/ssh/orcad-state-mutation-timeout.test.ts": 460, + "src/main/ssh/orcad-state-snapshot-shell.test.ts": 726, + "src/main/ssh/orcad-state-snapshot.test.ts": 382, + "src/main/ssh/orcad-stdio-bridge-provider.test.ts": 686, + "src/main/ssh/orcad-stdio-bridge.test.ts": 502, + "src/main/ssh/orcad-terminal-census-client.test.ts": 188, + "src/main/ssh/orcad-tunneled-pairing.test.ts": 209, + "src/main/ssh/orcad-unreachable-setup-release.test.ts": 1751, + "src/main/ssh/orcad-unreachable-setup-stop.test.ts": 424, + "src/main/ssh/orcad-update-plan.test.ts": 2663, + "src/main/ssh/orcad-windows-host-fence-ops.test.ts": 763, + "src/main/ssh/orcad-windows-host-lane.test.ts": 745, + "src/main/ssh/orcad-windows-host-state-ops.test.ts": 2035, + "src/main/ssh/pinned-runtime-materializer-node.test.ts": 114, + "src/main/ssh/pinned-runtime-materializer.test.ts": 3192, + "src/main/ssh/relay-bundle-paths.test.ts": 62, + "src/main/ssh/relay-protocol.test.ts": 120, + "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 107, + "src/main/ssh/relay-socket-path-limit.test.ts": 559, + "src/main/ssh/relay-version-dir-liveness.test.ts": 577, + "src/main/ssh/remote-install-coexistence.test.ts": 51, + "src/main/ssh/remote-install-gc-termination.test.ts": 267, + "src/main/ssh/remote-install-model.test.ts": 199, + "src/main/ssh/remote-install-previous-version.test.ts": 187, + "src/main/ssh/remote-node-runtime-store-gc.test.ts": 2001, + "src/main/ssh/remote-node-runtime-store-lock-host-release.test.ts": 2390, + "src/main/ssh/remote-node-runtime-store-windows.test.ts": 12643, + "src/main/ssh/removed-ssh-target-tombstone-retention.test.ts": 54, + "src/main/ssh/runtime-archive-download.test.ts": 99, + "src/main/ssh/runtime-ssh-access-verification.test.ts": 228, + "src/main/ssh/runtime-ssh-access.test.ts": 548, + "src/main/ssh/sftp-namespace-resolution.test.ts": 91, + "src/main/ssh/sftp-stream-late-error.test.ts": 232, + "src/main/ssh/sftp-upload.test.ts": 298, + "src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts": 530, + "src/main/ssh/ssh-agent-identity-filter.test.ts": 67, + "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts": 103, + "src/main/ssh/ssh-channel-multiplexer-saturation-wedge.test.ts": 72, + "src/main/ssh/ssh-channel-multiplexer-settlement.test.ts": 61, + "src/main/ssh/ssh-channel-multiplexer.test.ts": 133, + "src/main/ssh/ssh-config-alias-claim.test.ts": 87, + "src/main/ssh/ssh-config-host-picker.test.ts": 200, + "src/main/ssh/ssh-config-loader-regression.test.ts": 134, + "src/main/ssh/ssh-config-loader.test.ts": 117, + "src/main/ssh/ssh-config-parser-host-patterns.test.ts": 66, + "src/main/ssh/ssh-config-parser.test.ts": 327, + "src/main/ssh/ssh-config-resolver.test.ts": 72, + "src/main/ssh/ssh-connect-attempt-cancellation.test.ts": 94, + "src/main/ssh/ssh-connection-attribution.test.ts": 53, + "src/main/ssh/ssh-connection-auth-fallback.test.ts": 266, + "src/main/ssh/ssh-connection-channel-error.test.ts": 144, + "src/main/ssh/ssh-connection-channel-lifetime.test.ts": 41, + "src/main/ssh/ssh-connection-channel-open.test.ts": 932, + "src/main/ssh/ssh-connection-direct-startup.test.ts": 141, + "src/main/ssh/ssh-connection-file-transfer.test.ts": 178, + "src/main/ssh/ssh-connection-generation.test.ts": 52, + "src/main/ssh/ssh-connection-github-probe.test.ts": 196, + "src/main/ssh/ssh-connection-gssapi-fallback.test.ts": 254, + "src/main/ssh/ssh-connection-host-key-store-wiring.test.ts": 579, + "src/main/ssh/ssh-connection-host-key-verification.test.ts": 201, + "src/main/ssh/ssh-connection-manager-registry.test.ts": 258, + "src/main/ssh/ssh-connection-manager.test.ts": 73, + "src/main/ssh/ssh-connection-reconnect-handshake-loss.test.ts": 225, + "src/main/ssh/ssh-connection-reconnect-ladder.test.ts": 382, + "src/main/ssh/ssh-connection-sftp-namespace.test.ts": 991, + "src/main/ssh/ssh-connection-sftp-wire.test.ts": 368, + "src/main/ssh/ssh-connection-store-orcad-ownership.test.ts": 234, + "src/main/ssh/ssh-connection-store.test.ts": 320, + "src/main/ssh/ssh-connection-system-transport.test.ts": 241, + "src/main/ssh/ssh-connection-utils.test.ts": 217, + "src/main/ssh/ssh-connection.test.ts": 1261, + "src/main/ssh/ssh-control-socket.test.ts": 62, + "src/main/ssh/ssh-exec-rc-stdout-noise.test.ts": 255, + "src/main/ssh/ssh-exec-stdin-file-transfer.test.ts": 246, + "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 58, + "src/main/ssh/ssh-file-transfer-abort.test.ts": 105, + "src/main/ssh/ssh-g-config-resolution.test.ts": 112, + "src/main/ssh/ssh-git-response-pending-retention.test.ts": 238, + "src/main/ssh/ssh-git-response-stream-reader.test.ts": 59, + "src/main/ssh/ssh-git-stream-idle-timer.test.ts": 109, + "src/main/ssh/ssh-host-key-decision.test.ts": 74, + "src/main/ssh/ssh-host-key-store.test.ts": 193, + "src/main/ssh/ssh-host-key-verifier.test.ts": 92, + "src/main/ssh/ssh-host-platform-memo.test.ts": 40, + "src/main/ssh/ssh-host-relay-census-for-target.test.ts": 53, + "src/main/ssh/ssh-host-relay-endpoint-census.test.ts": 112, + "src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts": 576, + "src/main/ssh/ssh-host-relay-terminals-once-connected.test.ts": 616, + "src/main/ssh/ssh-host-relay-windows-census.test.ts": 209, + "src/main/ssh/ssh-host-relay-windows-inventory.test.ts": 179, + "src/main/ssh/ssh-host-server-on-connect-stopped-elsewhere.test.ts": 674, + "src/main/ssh/ssh-host-server-on-connect-telemetry.test.ts": 547, + "src/main/ssh/ssh-host-server-on-connect.test.ts": 539, + "src/main/ssh/ssh-host-server-telemetry.test.ts": 228, + "src/main/ssh/ssh-hostile-host-cells.test.ts": 137, + "src/main/ssh/ssh-hostile-host-local-sshd.test.ts": 69, + "src/main/ssh/ssh-hostile-host-observer.test.ts": 79, + "src/main/ssh/ssh-keyboard-interactive-wire.test.ts": 290, + "src/main/ssh/ssh-keyboard-interactive.test.ts": 72, + "src/main/ssh/ssh-known-hosts-source.test.ts": 128, + "src/main/ssh/ssh-known-hosts.test.ts": 96, + "src/main/ssh/ssh-legacy-relay-route.test.ts": 107, + "src/main/ssh/ssh-legacy-relay-router.test.ts": 55, + "src/main/ssh/ssh-legacy-relay-routing.test.ts": 111, + "src/main/ssh/ssh-listing-response-retention.test.ts": 77, + "src/main/ssh/ssh-multi-factor-authentication.test.ts": 168, + "src/main/ssh/ssh-multi-key-authentication.test.ts": 126, + "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 89, + "src/main/ssh/ssh-multiplexer-writer-retention.test.ts": 480, + "src/main/ssh/ssh-owner-recovery-retry.test.ts": 78, + "src/main/ssh/ssh-port-forward.test.ts": 253, + "src/main/ssh/ssh-posix-command-wrapper.test.ts": 140, + "src/main/ssh/ssh-previous-relay-terminals.test.ts": 209, + "src/main/ssh/ssh-provider-authority.test.ts": 67, + "src/main/ssh/ssh-proxy-command.test.ts": 120, + "src/main/ssh/ssh-pty-consumer-session.test.ts": 63, + "src/main/ssh/ssh-quit-abort-upload.test.ts": 256, + "src/main/ssh/ssh-reconnect-error-classification.test.ts": 123, + "src/main/ssh/ssh-reconnect-ladder.test.ts": 96, + "src/main/ssh/ssh-relay-answered-failure-producers.test.ts": 301, + "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 571, + "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2228, + "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 115, + "src/main/ssh/ssh-relay-endpoint-pty-count.test.ts": 695, + "src/main/ssh/ssh-relay-endpoint-runtime.test.ts": 102, + "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 141, + "src/main/ssh/ssh-relay-exec-input.test.ts": 91, + "src/main/ssh/ssh-relay-gc-claim-termination.test.ts": 261, + "src/main/ssh/ssh-relay-host-answered-failure.test.ts": 100, + "src/main/ssh/ssh-relay-incumbent-process.test.ts": 5678, + "src/main/ssh/ssh-relay-install-lock-exited-owner.test.ts": 324, + "src/main/ssh/ssh-relay-install-lock-termination.test.ts": 245, + "src/main/ssh/ssh-relay-install-lock.test.ts": 178, + "src/main/ssh/ssh-relay-install-namespace.test.ts": 227, + "src/main/ssh/ssh-relay-install-noisy-sftp-wire.test.ts": 292, + "src/main/ssh/ssh-relay-install-transfers-exec-fallback.test.ts": 113, + "src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts": 706, + "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 421, + "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 220, + "src/main/ssh/ssh-relay-pinned-node.integration.test.ts": 352, + "src/main/ssh/ssh-relay-pinned-node.test.ts": 421, + "src/main/ssh/ssh-relay-runtime-ladder.test.ts": 346, + "src/main/ssh/ssh-relay-runtime-never-worse.test.ts": 422, + "src/main/ssh/ssh-relay-runtime-resolution.test.ts": 438, + "src/main/ssh/ssh-relay-runtime-self-test.test.ts": 655, + "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 165, + "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 146, + "src/main/ssh/ssh-relay-versioned-install-termination.test.ts": 239, + "src/main/ssh/ssh-relay-versioned-install.test.ts": 327, + "src/main/ssh/ssh-relay-windows-launch-command.test.ts": 151, + "src/main/ssh/ssh-remote-cli-launcher.test.ts": 145, + "src/main/ssh/ssh-remote-commands.test.ts": 7310, + "src/main/ssh/ssh-remote-node-resolution.test.ts": 318, + "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 173, + "src/main/ssh/ssh-remote-platform-detection.test.ts": 218, + "src/main/ssh/ssh-remote-platform.test.ts": 58, + "src/main/ssh/ssh-remote-powershell.test.ts": 328, + "src/main/ssh/ssh-remote-runtime-telemetry.test.ts": 286, + "src/main/ssh/ssh-remote-windows-command-line-limit.test.ts": 208, + "src/main/ssh/ssh-request-outcome-verdict.test.ts": 52, + "src/main/ssh/ssh-response-pending-frames.test.ts": 96, + "src/main/ssh/ssh-security-key-identity.test.ts": 156, + "src/main/ssh/ssh-session-command-audit.test.ts": 126, + "src/main/ssh/ssh-session-limit-error.test.ts": 49, + "src/main/ssh/ssh-system-fallback.test.ts": 307, + "src/main/ssh/ssh-system-transport.integration.test.ts": 1301, + "src/main/ssh/ssh-target-id-migration.test.ts": 62, + "src/main/ssh/ssh-target-orcad-claims.test.ts": 221, + "src/main/ssh/ssh-target-orcad-dependents.test.ts": 247, + "src/main/ssh/ssh-target-orcad-preflight.test.ts": 1492, + "src/main/ssh/ssh-target-readoption.test.ts": 56, + "src/main/ssh/ssh-tcp-forwarding-probe.test.ts": 109, + "src/main/ssh/ssh-windows-host-cells.test.ts": 67, + "src/main/ssh/ssh2-port-forward-listen.test.ts": 49, + "src/main/ssh/system-ssh-binary.test.ts": 62, + "src/main/ssh/system-ssh-dynamic-forward-process.test.ts": 236, + "src/main/ssh/system-ssh-forward-process.test.ts": 228, + "src/main/ssh/system-ssh-sftp-args.test.ts": 57, "src/main/ssh/system-ssh-sftp-path.test.ts": 54, - "src/main/ssh/system-ssh-windows-upload.test.ts": 415, - "src/main/ssh/system-ssh-windows-write-capabilities.test.ts": 56, - "src/main/ssh/vscode-ssh-authority.test.ts": 73, - "src/main/star-nag/service-direct-star.test.ts": 171, - "src/main/star-nag/service-force-show-races.test.ts": 260, - "src/main/star-nag/service-outcome-telemetry.test.ts": 189, - "src/main/star-nag/service-prompt-moments.test.ts": 229, - "src/main/star-nag/service-threshold-prompt.test.ts": 214, - "src/main/startup/bootstrap-fatal-exit-guard.test.ts": 26, - "src/main/startup/branch-rename-hook-structured-session.test.ts": 91, - "src/main/startup/browser-process-user-agent-ordering.test.ts": 3534, - "src/main/startup/cli-launch-redirect.test.ts": 77, - "src/main/startup/configure-process-dev-parent-shutdown.test.ts": 138, - "src/main/startup/configure-process-profile-state.test.ts": 86, - "src/main/startup/configure-process.test.ts": 457, - "src/main/startup/desktop-startup-ordering.test.ts": 54, - "src/main/startup/dev-education-suppression.test.ts": 195, - "src/main/startup/dev-instance-identity.test.ts": 49, - "src/main/startup/ensure-virtual-display.test.ts": 5118, - "src/main/startup/first-window-startup-services.test.ts": 59, - "src/main/startup/gpu-fallback-marker.test.ts": 51, - "src/main/startup/gpu-fallback-switches.test.ts": 61, - "src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 549, - "src/main/startup/headless-pty-hydration-ordering.test.ts": 42, - "src/main/startup/host-port-bootstrap-wiring.test.ts": 37, - "src/main/startup/http1-compatibility-profile-state.test.ts": 65, - "src/main/startup/hydrate-shell-path.test.ts": 258, - "src/main/startup/hydrate-shell-path.windows.test.ts": 319, - "src/main/startup/legacy-worker-renderer-recovery.test.ts": 59, - "src/main/startup/login-shell-environment-cache.test.ts": 87, - "src/main/startup/login-shell-environment.test.ts": 112, - "src/main/startup/main-process-error-guards.test.ts": 73, - "src/main/startup/main-process-ready-activation-failure.test.ts": 351, - "src/main/startup/main-process-ready-identity-write.test.ts": 356, - "src/main/startup/main-process-ready-persistence-cleanup.test.ts": 216, - "src/main/startup/main-process-ready-phase-ordering.test.ts": 230, - "src/main/startup/main-process-runtime-launch-activation.test.ts": 356, - "src/main/startup/main-window-core-services.test.ts": 39, - "src/main/startup/main-window-structured-status-filter.test.ts": 347, - "src/main/startup/os-opened-markdown-delivery.test.ts": 218, - "src/main/startup/os-opened-markdown-files.test.ts": 130, - "src/main/startup/os-opened-markdown-wiring.test.ts": 48, - "src/main/startup/pre-gone-crash-sampling-wiring.test.ts": 36, - "src/main/startup/profile-state-recovery-preflight.test.ts": 556, - "src/main/startup/profile-state-write-failure.test.ts": 112, - "src/main/startup/renderer-heap-headroom.test.ts": 55, - "src/main/startup/run-electron-vite-dev-web.test.ts": 588, - "src/main/startup/run-electron-vite-dev.test.ts": 968, - "src/main/startup/secret-protection-report-deferral-wiring.test.ts": 58, - "src/main/startup/secure-dns-census.test.ts": 789, - "src/main/startup/serve-desktop-activation-wiring.test.ts": 30, - "src/main/startup/serve-desktop-activation.test.ts": 40, - "src/main/startup/serve-mode-argv-cli-redirect-order.test.ts": 53, - "src/main/startup/serve-mode-argv.test.ts": 85, - "src/main/startup/serve-options.test.ts": 67, - "src/main/startup/serve-signal-handlers.test.ts": 52, - "src/main/startup/single-instance-lock-exit.electron.test.ts": 622, - "src/main/startup/single-instance-lock-headless-exit.test.ts": 44, - "src/main/startup/single-instance-lock.test.ts": 59, - "src/main/startup/skill-share-deep-link-state.test.ts": 44, - "src/main/startup/startup-diagnostics.test.ts": 61, - "src/main/startup/window-all-closed-quit-policy.test.ts": 32, - "src/main/startup/windows-desktop-shell-path-startup.test.ts": 44, - "src/main/startup/windows-install-dir-acl-probe.test.ts": 147, - "src/main/startup/windows-install-dir-acl-recovery.test.ts": 887, - "src/main/startup/windows-install-dir-acl-repair.win32.test.ts": 78, - "src/main/startup/windows-install-dir-acl-startup-wiring.test.ts": 42, - "src/main/startup/windows-install-dir-package-acl-repair.test.ts": 112, - "src/main/startup/windows-shell-path-hydration.test.ts": 383, - "src/main/startup/windows-shell-path-ownership.test.ts": 62, - "src/main/startup/windows-shell-path-restoration.windows.test.ts": 51, - "src/main/startup/windows-user-data-acl.test.ts": 69, - "src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts": 34, - "src/main/stats/agent-session-transition-recorder.test.ts": 665, - "src/main/stats/collector-async-save.test.ts": 1081, - "src/main/stats/stats-title-detection-independence.test.ts": 45, - "src/main/synthetic-title-frame-routing.test.ts": 27, - "src/main/synthetic-title-spinner.test.ts": 52, - "src/main/synthetic-title-visibility.test.ts": 24, + "src/main/ssh/system-ssh-upload-plan-budget.test.ts": 344, + "src/main/ssh/system-ssh-windows-upload.test.ts": 379, + "src/main/ssh/system-ssh-windows-write-capabilities.test.ts": 87, + "src/main/ssh/vscode-ssh-authority.test.ts": 71, + "src/main/star-nag/service-direct-star.test.ts": 381, + "src/main/star-nag/service-force-show-races.test.ts": 346, + "src/main/star-nag/service-outcome-telemetry.test.ts": 353, + "src/main/star-nag/service-prompt-moments.test.ts": 340, + "src/main/star-nag/service-threshold-prompt.test.ts": 410, + "src/main/startup/bootstrap-fatal-exit-guard.test.ts": 55, + "src/main/startup/branch-rename-hook-structured-session.test.ts": 197, + "src/main/startup/browser-process-user-agent-ordering.test.ts": 5756, + "src/main/startup/cli-launch-redirect.test.ts": 114, + "src/main/startup/codex-account-restart-resume.test.ts": 292, + "src/main/startup/codex-hook-startup-wiring.test.ts": 6575, + "src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts": 187, + "src/main/startup/codex-session-resume-daemon-guard.test.ts": 143, + "src/main/startup/configure-process-dev-parent-shutdown.test.ts": 113, + "src/main/startup/configure-process-profile-state.test.ts": 143, + "src/main/startup/configure-process.test.ts": 235, + "src/main/startup/desktop-profile-instance-lock.test.ts": 219, + "src/main/startup/dev-education-suppression.test.ts": 306, + "src/main/startup/dev-instance-identity.test.ts": 57, + "src/main/startup/ensure-virtual-display.test.ts": 77, + "src/main/startup/first-window-startup-services.test.ts": 83, + "src/main/startup/gpu-fallback-marker.test.ts": 75, + "src/main/startup/gpu-fallback-switches.test.ts": 52, + "src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 546, + "src/main/startup/headless-serve-ssh-registration.test.ts": 4078, + "src/main/startup/http1-compatibility-profile-state.test.ts": 90, + "src/main/startup/hydrate-shell-path.test.ts": 451, + "src/main/startup/hydrate-shell-path.windows.test.ts": 322, + "src/main/startup/legacy-worker-renderer-recovery.test.ts": 100, + "src/main/startup/linux-dev-shm-policy.test.ts": 74, + "src/main/startup/linux-keyring-backend-selection.test.ts": 73, + "src/main/startup/linux-secret-service-probe.test.ts": 51, + "src/main/startup/login-shell-environment-cache.test.ts": 90, + "src/main/startup/login-shell-environment.test.ts": 104, + "src/main/startup/main-process-error-guards.test.ts": 108, + "src/main/startup/main-process-observers-hang-breadcrumb.test.ts": 160, + "src/main/startup/main-process-quit-pending-writer.test.ts": 118, + "src/main/startup/main-process-quit-update-veto.test.ts": 91, + "src/main/startup/main-process-ready-activation-failure.test.ts": 261, + "src/main/startup/main-process-ready-identity-write.test.ts": 1168, + "src/main/startup/main-process-ready-persistence-cleanup.test.ts": 213, + "src/main/startup/main-process-ready-phase-ordering.test.ts": 235, + "src/main/startup/main-process-runtime-launch-activation.test.ts": 5532, + "src/main/startup/main-window-actions-user-quit.test.ts": 193, + "src/main/startup/main-window-core-services.test.ts": 87, + "src/main/startup/main-window-structured-status-filter.test.ts": 195, + "src/main/startup/os-opened-document-arguments.test.ts": 167, + "src/main/startup/os-opened-document-delivery.test.ts": 168, + "src/main/startup/os-opened-document-resolution.test.ts": 254, + "src/main/startup/os-opened-documents.test.ts": 180, + "src/main/startup/profile-state-recovery-preflight.test.ts": 690, + "src/main/startup/profile-state-save-delay.test.ts": 53, + "src/main/startup/profile-state-write-failure.test.ts": 446, + "src/main/startup/renderer-heap-headroom.test.ts": 54, + "src/main/startup/run-electron-vite-dev-web.test.ts": 541, + "src/main/startup/run-electron-vite-dev.test.ts": 914, + "src/main/startup/select-linux-keyring-backend.test.ts": 50, + "src/main/startup/serve-desktop-activation.test.ts": 48, + "src/main/startup/serve-mode-argv-cli-redirect-order.test.ts": 85, + "src/main/startup/serve-mode-argv.test.ts": 88, + "src/main/startup/serve-native-quit-guard.test.ts": 55, + "src/main/startup/serve-options.test.ts": 104, + "src/main/startup/serve-signal-handlers.test.ts": 53, + "src/main/startup/serve-user-data-path-guard.test.ts": 165, + "src/main/startup/single-instance-lock-exit.electron.test.ts": 402, + "src/main/startup/single-instance-lock.test.ts": 80, + "src/main/startup/skill-share-deep-link-state.test.ts": 52, + "src/main/startup/startup-diagnostics.test.ts": 51, + "src/main/startup/window-all-closed-quit-policy.test.ts": 48, + "src/main/startup/windows-app-data-path.test.ts": 53, + "src/main/startup/windows-desktop-shell-path-startup.test.ts": 99, + "src/main/startup/windows-install-dir-acl-probe.test.ts": 144, + "src/main/startup/windows-install-dir-acl-recovery.test.ts": 578, + "src/main/startup/windows-install-dir-acl-repair.win32.test.ts": 124, + "src/main/startup/windows-install-dir-package-acl-repair.test.ts": 153, + "src/main/startup/windows-shell-path-hydration.test.ts": 388, + "src/main/startup/windows-shell-path-ownership.test.ts": 56, + "src/main/startup/windows-shell-path-restoration.windows.test.ts": 48, + "src/main/startup/windows-user-data-acl.test.ts": 73, + "src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts": 58, + "src/main/stats/agent-session-transition-recorder.test.ts": 604, + "src/main/stats/collector-async-save.test.ts": 513, + "src/main/synthetic-title-frame-routing.test.ts": 42, + "src/main/synthetic-title-spinner.test.ts": 51, + "src/main/synthetic-title-visibility.test.ts": 37, "src/main/system-fonts.test.ts": 80, - "src/main/system-power-lifecycle.test.ts": 54, - "src/main/system-resume-broadcast.test.ts": 84, - "src/main/telemetry/burst-cap.test.ts": 206, - "src/main/telemetry/classify-error.test.ts": 43, - "src/main/telemetry/client-lifecycle.test.ts": 155, - "src/main/telemetry/client.test.ts": 258, - "src/main/telemetry/cohort-classifier.test.ts": 59, - "src/main/telemetry/consent.test.ts": 31, - "src/main/telemetry/install-id.test.ts": 65, - "src/main/telemetry/onboarding-cohort-classifier.test.ts": 171, - "src/main/telemetry/onboarding-feature-setup-validator.test.ts": 172, - "src/main/telemetry/validator-warn-cache.test.ts": 168, - "src/main/telemetry/validator.test.ts": 128, - "src/main/terminal-history-async-delete.test.ts": 569, - "src/main/terminal-history-gc-fs-call-count.test.ts": 181, - "src/main/terminal-history-gc.test.ts": 951, - "src/main/terminal-history-tombstone-retry.test.ts": 982, - "src/main/terminal-history-tombstone-scan-cost.test.ts": 428, - "src/main/terminal-history.test.ts": 92, - "src/main/text-generation/agent-failure-output.test.ts": 31, - "src/main/text-generation/command-environment-process.test.ts": 562, - "src/main/text-generation/command-environment-wsl.test.ts": 56, - "src/main/text-generation/commit-message-agent-environment.test.ts": 50, - "src/main/text-generation/commit-message-command-backslash-mode.test.ts": 182, - "src/main/text-generation/commit-message-text-generation-branch-name.test.ts": 172, - "src/main/text-generation/commit-message-text-generation-cancellation.test.ts": 672, - "src/main/text-generation/commit-message-text-generation-failure-sanitization.test.ts": 127, - "src/main/text-generation/commit-message-text-generation-generated-output.test.ts": 149, - "src/main/text-generation/commit-message-text-generation-linked-issue.test.ts": 216, - "src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts": 228, - "src/main/text-generation/commit-message-text-generation-model-discovery.test.ts": 214, - "src/main/text-generation/commit-message-text-generation-regression.test.ts": 223, - "src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 215, - "src/main/text-generation/commit-message-text-generation-settings.test.ts": 299, - "src/main/text-generation/omp-model-discovery-policy.test.ts": 112, - "src/main/text-generation/pull-request-context-errors.test.ts": 61, - "src/main/text-generation/pull-request-context.test.ts": 58, - "src/main/tray/system-tray.test.ts": 134, - "src/main/tray/tray-attention-icon.test.ts": 58, - "src/main/tray/tray-dev-badge.test.ts": 60, - "src/main/update-install-exit-watchdog.test.ts": 64, - "src/main/updater-changelog.test.ts": 79, - "src/main/updater-events.test.ts": 205, - "src/main/updater-lifecycle-diagnostics.test.ts": 56, - "src/main/updater-linux-package-recovery-actions.test.ts": 2071, - "src/main/updater-nudge.test.ts": 74, - "src/main/updater-prerelease-feed-readiness.test.ts": 204, - "src/main/updater-prerelease-feed.test.ts": 210, - "src/main/updater-release-api-token.test.ts": 62, - "src/main/updater-release-build-cache.test.ts": 38, - "src/main/updater-release-builds.test.ts": 164, - "src/main/updater-test-harness.leaked-timers.test.ts": 2646, - "src/main/updater-test-module-loader.test.ts": 540, - "src/main/updater.build-channel-selection.test.ts": 2969, - "src/main/updater.check-failure.test.ts": 2612, - "src/main/updater.check-preflight.test.ts": 2566, - "src/main/updater.check-settlement.test.ts": 2937, - "src/main/updater.fallback.test.ts": 51, - "src/main/updater.feed-attempt-lifetime.test.ts": 1577, - "src/main/updater.headless-serve-install.test.ts": 315, - "src/main/updater.install-failure-cause.test.ts": 3143, - "src/main/updater.linux-externally-managed.test.ts": 2828, - "src/main/updater.linux-root-package-install.test.ts": 2144, - "src/main/updater.mac-install.test.ts": 54, - "src/main/updater.nudge-campaign.test.ts": 1952, - "src/main/updater.prerelease-fallback.test.ts": 4132, - "src/main/updater.publishing-window-feed.test.ts": 2272, - "src/main/updater.quit-and-install.test.ts": 2406, - "src/main/updater.startup-scheduling.test.ts": 2192, - "src/main/usage-worktree-canonicalizer.test.ts": 38, - "src/main/usage-worktree-metadata.test.ts": 57, + "src/main/system-power-lifecycle.test.ts": 43, + "src/main/system-resume-broadcast.test.ts": 68, + "src/main/telemetry/burst-cap.test.ts": 343, + "src/main/telemetry/classify-error.test.ts": 48, + "src/main/telemetry/client-lifecycle.test.ts": 359, + "src/main/telemetry/client.test.ts": 344, + "src/main/telemetry/cohort-classifier.test.ts": 42, + "src/main/telemetry/consent.test.ts": 71, + "src/main/telemetry/install-id.test.ts": 79, + "src/main/telemetry/onboarding-cohort-classifier.test.ts": 70, + "src/main/telemetry/onboarding-feature-setup-validator.test.ts": 277, + "src/main/telemetry/validator-warn-cache.test.ts": 290, + "src/main/telemetry/validator.test.ts": 364, + "src/main/terminal-history-async-delete.test.ts": 402, + "src/main/terminal-history-gc-fs-call-count.test.ts": 176, + "src/main/terminal-history-gc.test.ts": 821, + "src/main/terminal-history-tombstone-retry.test.ts": 764, + "src/main/terminal-history-tombstone-scan-cost.test.ts": 319, + "src/main/terminal-history.test.ts": 153, + "src/main/text-generation/agent-failure-output.test.ts": 57, + "src/main/text-generation/command-environment-process.test.ts": 915, + "src/main/text-generation/command-environment-wsl.test.ts": 83, + "src/main/text-generation/commit-message-agent-environment.test.ts": 73, + "src/main/text-generation/commit-message-command-backslash-mode.test.ts": 499, + "src/main/text-generation/commit-message-text-generation-branch-name.test.ts": 498, + "src/main/text-generation/commit-message-text-generation-cancellation.test.ts": 1070, + "src/main/text-generation/commit-message-text-generation-failure-sanitization.test.ts": 406, + "src/main/text-generation/commit-message-text-generation-generated-output.test.ts": 426, + "src/main/text-generation/commit-message-text-generation-linked-issue.test.ts": 401, + "src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts": 463, + "src/main/text-generation/commit-message-text-generation-model-discovery.test.ts": 504, + "src/main/text-generation/commit-message-text-generation-regression.test.ts": 463, + "src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 418, + "src/main/text-generation/commit-message-text-generation-settings.test.ts": 451, + "src/main/text-generation/conversation-name-generation-request.test.ts": 355, + "src/main/text-generation/omp-model-discovery-policy.test.ts": 251, + "src/main/text-generation/opencode-generation-events.test.ts": 351, + "src/main/text-generation/pull-request-context-errors.test.ts": 71, + "src/main/text-generation/pull-request-context.test.ts": 89, + "src/main/text-generation/source-control-agent-launch.test.ts": 1915, + "src/main/text-generation/source-control-local-process.test.ts": 1522, + "src/main/tray/system-tray.test.ts": 178, + "src/main/tray/tray-attention-icon.test.ts": 63, + "src/main/tray/tray-dev-badge.test.ts": 56, + "src/main/update-install-exit-watchdog.test.ts": 77, + "src/main/updater-agent-state-rules-release.test.ts": 103, + "src/main/updater-changelog.test.ts": 63, + "src/main/updater-events.test.ts": 227, + "src/main/updater-lifecycle-diagnostics.test.ts": 57, + "src/main/updater-linux-package-recovery-actions.test.ts": 4060, + "src/main/updater-mac-quit-guard.test.ts": 40, + "src/main/updater-nudge.test.ts": 247, + "src/main/updater-prerelease-feed-readiness.test.ts": 156, + "src/main/updater-prerelease-feed.test.ts": 237, + "src/main/updater-release-api-token.test.ts": 63, + "src/main/updater-release-build-cache.test.ts": 58, + "src/main/updater-release-builds.test.ts": 95, + "src/main/updater-sqlite-profile-downgrade.test.ts": 1672, + "src/main/updater.build-channel-selection.test.ts": 3459, + "src/main/updater.check-failure.test.ts": 3131, + "src/main/updater.check-preflight.test.ts": 4281, + "src/main/updater.check-settlement.test.ts": 3437, + "src/main/updater.fallback.test.ts": 54, + "src/main/updater.feed-attempt-lifetime.test.ts": 2886, + "src/main/updater.headless-serve-install.test.ts": 431, + "src/main/updater.install-failure-cause.test.ts": 3737, + "src/main/updater.linux-externally-managed.test.ts": 2482, + "src/main/updater.linux-root-package-install.test.ts": 2779, + "src/main/updater.mac-install.test.ts": 55, + "src/main/updater.nudge-campaign.test.ts": 2950, + "src/main/updater.prerelease-fallback.test.ts": 4430, + "src/main/updater.publishing-window-feed.test.ts": 4333, + "src/main/updater.quit-and-install.test.ts": 4367, + "src/main/updater.startup-scheduling.test.ts": 3238, + "src/main/updater/rollout-flags.test.ts": 338, + "src/main/usage-worktree-canonicalizer.test.ts": 47, + "src/main/usage-worktree-metadata.test.ts": 60, + "src/main/usage/agent-token-usage-reporter.test.ts": 802, + "src/main/usage/agent-token-usage.test.ts": 46, + "src/main/usage/analytics-session-id-store.test.ts": 247, "src/main/usage/highest-usage-key.test.ts": 68, - "src/main/usage/jsonl-line-offsets.test.ts": 64, - "src/main/usage/usage-breakdown-scaling.test.ts": 109, - "src/main/usage/usage-calendar-range.test.ts": 62, - "src/main/usage/usage-provider-store-lifecycle.test.ts": 245, - "src/main/usage/usage-scan-worker-client.test.ts": 70, - "src/main/usage/usage-scan-worker-event-loop.test.ts": 3290, - "src/main/usage/usage-worktree-refs.test.ts": 45, - "src/main/usage/usage-worktree-resolver.test.ts": 114, - "src/main/warp-themes/discovery.test.ts": 98, - "src/main/warp-themes/index.test.ts": 679, - "src/main/warp-themes/manual-warp-theme-files.test.ts": 106, - "src/main/warp-themes/parser-runner.test.ts": 65, - "src/main/warp-themes/parser.test.ts": 55, - "src/main/warp-themes/theme-file-scanner.test.ts": 66, - "src/main/win32-utils-async-acl.test.ts": 49, - "src/main/win32-utils.test.ts": 73, - "src/main/window/attach-main-window-services.test.ts": 13208, - "src/main/window/clipboard-dashboard-popout-access.test.ts": 330, - "src/main/window/clipboard-file-copy.test.ts": 38, - "src/main/window/clipboard-image-temp-file.test.ts": 46, - "src/main/window/clipboard-image-thumbnail.test.ts": 35, - "src/main/window/clipboard-ipc-handlers.test.ts": 188, - "src/main/window/clipboard-remote-file-cleanup.bench.test.ts": 30, - "src/main/window/clipboard-remote-file-copy.test.ts": 70, - "src/main/window/clipboard-remote-file-staging-fs.test.ts": 56, - "src/main/window/clipboard-remote-file-staging.test.ts": 141, - "src/main/window/clipboard-runtime-owned-ssh-paste.test.ts": 99, - "src/main/window/clipboard-text-write-verify.test.ts": 37, - "src/main/window/clipboard-windows-image-file.test.ts": 57, - "src/main/window/createMainWindow-close-confirmation.test.ts": 1201, - "src/main/window/createMainWindow-markdown-editor-focus.test.ts": 1169, - "src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 1850, - "src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 1634, - "src/main/window/createMainWindow-startup-reveal.test.ts": 1360, - "src/main/window/createMainWindow-system-resume-relay.test.ts": 1385, - "src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 1341, - "src/main/window/createMainWindow-tray-minimize-close.test.ts": 876, - "src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts": 1287, - "src/main/window/createMainWindow.test.ts": 2476, - "src/main/window/dashboard-popout-window.test.ts": 162, - "src/main/window/editable-context-menu.test.ts": 89, - "src/main/window/focus-existing-window.test.ts": 75, - "src/main/window/foreground-activation-policy.test.ts": 36, - "src/main/window/history-gc-profile-worktree-ids.test.ts": 246, - "src/main/window/history-gc-worktree-ids.test.ts": 319, - "src/main/window/macos-app-activation.test.ts": 40, - "src/main/window/macos-tahoe-release.test.ts": 39, - "src/main/window/main-window-mouse-shortcuts.test.ts": 1533, - "src/main/window/main-window-visibility.test.ts": 38, - "src/main/window/main-window-webview-security.test.ts": 165, - "src/main/window/mobile-markdown-request-relay.test.ts": 57, - "src/main/window/privileged-window-navigation.test.ts": 178, - "src/main/window/renderer-document-navigation.test.ts": 40, - "src/main/window/renderer-publication-throttle.test.ts": 40, - "src/main/window/renderer-recovery-prompt.test.ts": 100, - "src/main/window/renderer-recovery-reload-watchdog.test.ts": 86, - "src/main/window/runtime-renderer-notification-sender.test.ts": 44, - "src/main/window/session-tab-close-request-relay.test.ts": 82, - "src/main/window/terminal-tab-close-request-relay.test.ts": 55, - "src/main/window/updater-package-recovery-ipc.test.ts": 4430, - "src/main/window/window-close-decision.test.ts": 41, - "src/main/windows-live-tree-kill.win32.test.ts": 46, - "src/main/windows-process-tree-kill.test.ts": 61, - "src/main/windows-pty-root-identity.test.ts": 94, - "src/main/windows-registry-addon.test.ts": 33, - "src/main/windows/windows-command-line-recovery-health.test.ts": 49, - "src/main/windows/windows-host-job.win32.test.ts": 31, - "src/main/windows/windows-msys-job.win32.test.ts": 65, - "src/main/windows/windows-process-creation-time.test.ts": 51, - "src/main/windows/windows-process-table-cim-scan.test.ts": 51, - "src/main/windows/windows-process-table-native-addon.win32.test.ts": 37, - "src/main/windows/windows-process-table.test.ts": 261, - "src/main/windows/windows-process-tree-command-line-patch.test.ts": 37, - "src/main/windows/windows-pty-job.test.ts": 46, - "src/main/windows/windows-pty-job.win32.test.ts": 57, - "src/main/worker-thread-request-queue.test.ts": 75, - "src/main/workspace-cleanup-removal-snapshot-prune.test.ts": 70, - "src/main/workspace-cleanup-scan-snapshot.test.ts": 174, - "src/main/workspace-snapshot-pruning.bench.test.ts": 38, - "src/main/workspace-space-analysis-capacity.test.ts": 176, - "src/main/workspace-space-analysis-du-timeout.test.ts": 358, - "src/main/workspace-space-analysis-snapshot.test.ts": 134, - "src/main/workspace-space-analysis.test.ts": 359, - "src/main/workspace-space-repo-scan.test.ts": 330, - "src/main/workspace-space-scan-control.test.ts": 52, - "src/main/worktree-archive-hook-cannot-run.test.ts": 55, - "src/main/worktree-create-base-prefetch.test.ts": 287, - "src/main/worktree-create-base.test.ts": 37, - "src/main/worktree-create-candidates.test.ts": 38, - "src/main/worktree-create-execution-host-route.test.ts": 35, - "src/main/worktree-create-preparation-cancellation.test.ts": 148, - "src/main/worktree-create-preparation-claim.test.ts": 58, - "src/main/worktree-create-preparation-contention.test.ts": 95, - "src/main/worktree-create-preparation-timing.test.ts": 72, - "src/main/worktree-create-preparation-wsl-root.test.ts": 149, - "src/main/worktree-create-preparation.test.ts": 545, - "src/main/worktree-create-timing.test.ts": 39, - "src/main/worktree-identity-persistence.test.ts": 1325, - "src/main/worktree-lineage-pruning.test.ts": 81, - "src/main/worktree-metadata-ownership.test.ts": 39, - "src/main/worktree-name-retirement.test.ts": 115, - "src/main/worktree-prunable-git-file.test.ts": 58, - "src/main/worktree-removal-authority.test.ts": 64, - "src/main/worktree-removal-execution-host-route.test.ts": 52, - "src/main/worktree-removal-home-guard.test.ts": 88, - "src/main/worktree-removal-repo-owner.test.ts": 64, - "src/main/worktree-removal-safety.test.ts": 115, - "src/main/worktree-removal-session-partition-fencing.test.ts": 1523, - "src/main/worktree-retirement-backfill-scan.test.ts": 44, + "src/main/usage/jsonl-file-reader.test.ts": 87, + "src/main/usage/jsonl-file-snapshot.test.ts": 73, + "src/main/usage/jsonl-line-offsets.test.ts": 67, + "src/main/usage/usage-breakdown-scaling.test.ts": 93, + "src/main/usage/usage-calendar-range.test.ts": 59, + "src/main/usage/usage-provider-store-initialization.test.ts": 734, + "src/main/usage/usage-provider-store-lifecycle.test.ts": 751, + "src/main/usage/usage-scan-worker-cache-cleanup.test.ts": 130, + "src/main/usage/usage-scan-worker-client.test.ts": 98, + "src/main/usage/usage-scan-worker-event-loop.test.ts": 2406, + "src/main/usage/usage-source-cache-file.test.ts": 77, + "src/main/usage/usage-worktree-refs.test.ts": 43, + "src/main/usage/usage-worktree-resolver.test.ts": 125, + "src/main/warp-themes/discovery.test.ts": 76, + "src/main/warp-themes/index.test.ts": 685, + "src/main/warp-themes/manual-warp-theme-files.test.ts": 61, + "src/main/warp-themes/parser-runner.test.ts": 55, + "src/main/warp-themes/parser.test.ts": 108, + "src/main/warp-themes/theme-file-scanner.test.ts": 70, + "src/main/win32-utils-async-acl.test.ts": 56, + "src/main/win32-utils.test.ts": 70, + "src/main/window/attach-main-window-services.test.ts": 1420, + "src/main/window/clipboard-agent-session-attachment-paste.test.ts": 438, + "src/main/window/clipboard-copied-file-paths.test.ts": 75, + "src/main/window/clipboard-dashboard-popout-access.test.ts": 1333, + "src/main/window/clipboard-file-copy.test.ts": 68, + "src/main/window/clipboard-image-source.test.ts": 60, + "src/main/window/clipboard-image-temp-file.test.ts": 61, + "src/main/window/clipboard-image-thumbnail.test.ts": 44, + "src/main/window/clipboard-ipc-handlers.test.ts": 477, + "src/main/window/clipboard-remote-file-cleanup.bench.test.ts": 46, + "src/main/window/clipboard-remote-file-copy.test.ts": 82, + "src/main/window/clipboard-remote-file-staging-fs.test.ts": 59, + "src/main/window/clipboard-remote-file-staging.test.ts": 107, + "src/main/window/clipboard-runtime-owned-ssh-paste.test.ts": 341, + "src/main/window/clipboard-text-write-verify.test.ts": 62, + "src/main/window/clipboard-windows-image-file.test.ts": 91, + "src/main/window/createMainWindow-close-confirmation.test.ts": 2132, + "src/main/window/createMainWindow-low-commit-oom-recovery.test.ts": 1748, + "src/main/window/createMainWindow-markdown-editor-focus.test.ts": 1897, + "src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 2346, + "src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 2303, + "src/main/window/createMainWindow-renderer-launch-failure.test.ts": 1598, + "src/main/window/createMainWindow-startup-reveal.test.ts": 2157, + "src/main/window/createMainWindow-system-resume-relay.test.ts": 2342, + "src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 1846, + "src/main/window/createMainWindow-tray-minimize-close.test.ts": 1969, + "src/main/window/createMainWindow-user-quit-close.test.ts": 2017, + "src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts": 1751, + "src/main/window/createMainWindow.test.ts": 2184, + "src/main/window/darwin-user-temp-dir.test.ts": 56, + "src/main/window/dashboard-popout-window.test.ts": 122, + "src/main/window/dragged-temp-file-copy.test.ts": 189, + "src/main/window/dropped-path-preparation-ipc.test.ts": 103, + "src/main/window/dropped-path-preparation.test.ts": 89, + "src/main/window/editable-context-menu.test.ts": 80, + "src/main/window/focus-existing-window.test.ts": 70, + "src/main/window/foreground-activation-policy.test.ts": 59, + "src/main/window/history-gc-worktree-ids.test.ts": 311, + "src/main/window/host-frame-navigation-guard.test.ts": 237, + "src/main/window/macos-app-activation.test.ts": 67, + "src/main/window/macos-tahoe-release.test.ts": 55, + "src/main/window/main-window-state-lifecycle.test.ts": 54, + "src/main/window/main-window-updater-deferred-setup.test.ts": 146, + "src/main/window/main-window-visibility.test.ts": 56, + "src/main/window/main-window-webview-security.test.ts": 344, + "src/main/window/mobile-markdown-request-relay.test.ts": 61, + "src/main/window/native-chat-paste-files.test.ts": 497, + "src/main/window/privileged-window-navigation.test.ts": 266, + "src/main/window/renderer-document-navigation.test.ts": 60, + "src/main/window/renderer-launch-failure-backoff.test.ts": 37, + "src/main/window/renderer-launch-failure-probe.spawn.test.ts": 71, + "src/main/window/renderer-launch-failure-probe.test.ts": 69, + "src/main/window/renderer-publication-throttle.test.ts": 55, + "src/main/window/renderer-recovery-prompt.test.ts": 109, + "src/main/window/renderer-recovery-reload-watchdog.test.ts": 76, + "src/main/window/runtime-renderer-notification-sender.test.ts": 47, + "src/main/window/runtime-window-lifecycle-file-open.test.ts": 57, + "src/main/window/session-tab-close-request-relay.test.ts": 74, + "src/main/window/terminal-tab-close-request-relay.test.ts": 63, + "src/main/window/updater-package-recovery-ipc.test.ts": 7052, + "src/main/window/window-close-decision.test.ts": 52, + "src/main/windows-live-tree-kill.win32.test.ts": 60, + "src/main/windows-process-tree-kill.test.ts": 81, + "src/main/windows-pty-root-identity.test.ts": 129, + "src/main/windows-registry-addon.test.ts": 41, + "src/main/windows/windows-command-line-recovery-health.test.ts": 50, + "src/main/windows/windows-host-job.win32.test.ts": 44, + "src/main/windows/windows-msys-job.win32.test.ts": 60, + "src/main/windows/windows-process-creation-time.test.ts": 80, + "src/main/windows/windows-process-table-cim-scan.test.ts": 63, + "src/main/windows/windows-process-table-native-addon.win32.test.ts": 60, + "src/main/windows/windows-process-table.test.ts": 177, + "src/main/windows/windows-process-tree-command-line-patch.test.ts": 61, + "src/main/windows/windows-pty-job.test.ts": 74, + "src/main/windows/windows-pty-job.win32.test.ts": 71, + "src/main/worker-thread-request-queue.test.ts": 94, + "src/main/workspace-cleanup-removal-snapshot-prune.test.ts": 78, + "src/main/workspace-cleanup-scan-snapshot.test.ts": 166, + "src/main/workspace-create-event-fields.test.ts": 300, + "src/main/workspace-snapshot-pruning.bench.test.ts": 53, + "src/main/workspace-space-analysis-capacity.test.ts": 475, + "src/main/workspace-space-analysis-du-timeout.test.ts": 587, + "src/main/workspace-space-analysis-snapshot.test.ts": 130, + "src/main/workspace-space-analysis.test.ts": 652, + "src/main/workspace-space-repo-scan.test.ts": 388, + "src/main/workspace-space-scan-control.test.ts": 55, + "src/main/worktree-archive-hook-cannot-run.test.ts": 53, + "src/main/worktree-background-removal-records.test.ts": 417, + "src/main/worktree-background-removal.test.ts": 346, + "src/main/worktree-create-base-prefetch.test.ts": 308, + "src/main/worktree-create-base.test.ts": 50, + "src/main/worktree-create-candidates.test.ts": 49, + "src/main/worktree-create-concurrency.test.ts": 67, + "src/main/worktree-create-execution-host-route.test.ts": 385, + "src/main/worktree-create-preparation-activity.test.ts": 43, + "src/main/worktree-create-preparation-cancellation.test.ts": 353, + "src/main/worktree-create-preparation-claim.test.ts": 55, + "src/main/worktree-create-preparation-contention.test.ts": 363, + "src/main/worktree-create-preparation-timing.test.ts": 316, + "src/main/worktree-create-preparation-wsl-root.test.ts": 348, + "src/main/worktree-create-preparation.test.ts": 299, + "src/main/worktree-create-timing.test.ts": 119, + "src/main/worktree-failed-removal.test.ts": 400, + "src/main/worktree-identity-persistence.test.ts": 1751, + "src/main/worktree-lineage-pruning.test.ts": 76, + "src/main/worktree-metadata-ownership.test.ts": 46, + "src/main/worktree-name-retirement.test.ts": 175, + "src/main/worktree-preparation-discard-retry.test.ts": 245, + "src/main/worktree-preparation-fetch-barrier.test.ts": 343, + "src/main/worktree-preparation-tip-refresh.test.ts": 500, + "src/main/worktree-prunable-git-file.test.ts": 88, + "src/main/worktree-removal-authority.test.ts": 104, + "src/main/worktree-removal-close-records.test.ts": 1393, + "src/main/worktree-removal-execution-host-route.test.ts": 397, + "src/main/worktree-removal-home-guard.test.ts": 94, + "src/main/worktree-removal-repo-owner.test.ts": 49, + "src/main/worktree-removal-safety.test.ts": 177, + "src/main/worktree-removal-session-partition-fencing.test.ts": 1465, + "src/main/worktree-retirement-backfill-scan.test.ts": 76, "src/main/worktree-retirement-backfill-stall.test.ts": 141, - "src/main/worktree-retirement-discovery-wsl.test.ts": 148, - "src/main/worktree-retirement-discovery.test.ts": 121, - "src/main/worktree-retirement-namespace.test.ts": 80, - "src/main/worktree-root-preparation.test.ts": 106, - "src/main/worktree-trash.test.ts": 203, - "src/main/wsl-approved-root-race.wsl.test.ts": 68, - "src/main/wsl-availability-missing-kernel.test.ts": 74, - "src/main/wsl-bash-command.test.ts": 25, - "src/main/wsl-distro-list-output.test.ts": 35, - "src/main/wsl-distro-list-single-flight.test.ts": 81, - "src/main/wsl-fish-history-cleanup.test.ts": 55, - "src/main/wsl-interop-spawn-directory.test.ts": 56, - "src/main/wsl-managed-cli-path-restore.test.ts": 57, - "src/main/wsl-running-distros.test.ts": 242, - "src/main/wsl-running-path-filter.test.ts": 47, - "src/main/wsl-unc-delete-symlink-repro.test.ts": 61, - "src/main/wsl-unc-delete.test.ts": 111, - "src/main/wsl-unc-delete.wsl.test.ts": 88, - "src/main/wsl.test.ts": 128, - "src/main/wsl/wsl-executable-path.win32.test.ts": 34, - "src/main/wsl/wsl-guest-environment.test.ts": 110, - "src/main/wsl/wsl-invocation-boundary.test.ts": 10984, - "src/main/wsl/wsl-probe-failure-semantics.test.ts": 357, - "src/main/wsl/wsl-runner.test.ts": 58, - "src/main/wsl/wsl-runner.wsl.test.ts": 64, - "src/main/wsl/wsl-w1-w3-contract.test.ts": 42, - "src/main/zcode/hook-service.test.ts": 89, - "src/main/zcode/interactive-capability.live.test.ts": 45, - "src/preload/api/platform-bridge.test.ts": 46, - "src/preload/app-restart-checkpoint-routing.test.ts": 934, - "src/preload/browser-client-page-renderer-requests.test.ts": 158, - "src/preload/browser-find-subscriptions.test.ts": 45, - "src/preload/browser-window-close.test.ts": 39, - "src/preload/close-active-tab-payload-admission.test.ts": 51, - "src/preload/doc-preview-link-interception.test.ts": 446, - "src/preload/pty-snapshot-capability-ipc.test.ts": 678, - "src/preload/renderer-heap-statistics-reader.test.ts": 30, - "src/preload/renderer-process-memory-reader.test.ts": 53, - "src/preload/renderer-restart-wiring.test.ts": 44, - "src/preload/runtime-environment-subscriptions.test.ts": 66, - "src/preload/ssh-authority-forwarding.test.ts": 604, - "src/preload/updater-package-recovery.test.ts": 534, - "src/preload/usage-provider-api.test.ts": 47, - "src/relay/agent-exec-handler-windows.test.ts": 71, - "src/relay/agent-exec-handler.test.ts": 157, - "src/wsl-guest/agent-hook-envelope-publication.test.ts": 351, - "src/wsl-guest/agent-hook-integration.test.ts": 848, - "src/relay/agent-hook-retired-pane-suppression.test.ts": 451, - "src/wsl-guest/agent-hook-server-codex-subagent-transcript.test.ts": 1327, - "src/wsl-guest/agent-hook-server.test.ts": 1396, - "src/relay/agent-status-store-relay-context.test.ts": 144, - "src/relay/ai-vault-handler.test.ts": 689, - "src/relay/ai-vault-opencode-reader.test.ts": 405, - "src/relay/ai-vault-service-client.test.ts": 89, - "src/relay/ai-vault-service-ready-retention.test.ts": 195, - "src/relay/ai-vault-service-restart-policy.test.ts": 69, - "src/relay/ai-vault-service-spawn.test.ts": 54, - "src/relay/context.test.ts": 39, - "src/wsl-guest/dispatcher-capacity-degradation.test.ts": 194, - "src/wsl-guest/dispatcher-client-close-cause.test.ts": 194, - "src/wsl-guest/dispatcher-client-writer.test.ts": 92, - "src/wsl-guest/dispatcher-frame-guard-regressions.test.ts": 169, - "src/wsl-guest/dispatcher-json-payload.test.ts": 122, - "src/wsl-guest/dispatcher-notification-ownership.test.ts": 140, - "src/wsl-guest/dispatcher-per-connection-state-baseline.test.ts": 146, - "src/wsl-guest/dispatcher-rpc-cancel-id-coercion.test.ts": 134, - "src/wsl-guest/dispatcher-silent-client-reaper.test.ts": 213, - "src/wsl-guest/dispatcher-structured-error.test.ts": 136, - "src/wsl-guest/dispatcher-timeout.test.ts": 210, - "src/wsl-guest/dispatcher-writer-admission.test.ts": 161, - "src/wsl-guest/dispatcher.test.ts": 306, - "src/relay/external-automation-provider-catalog.test.ts": 59, - "src/relay/external-automations-handler-log-path.test.ts": 46, - "src/relay/external-automations-handler.test.ts": 77, - "src/relay/fs-handler-doc-preview.test.ts": 302, - "src/relay/fs-handler-file-range-dispatch.test.ts": 303, - "src/relay/fs-handler-file-range.test.ts": 201, - "src/relay/fs-handler-git-search.test.ts": 102, - "src/relay/fs-handler-list-files-cancel.test.ts": 83, - "src/relay/fs-handler-list-files-ignored.test.ts": 816, - "src/relay/fs-handler-list-files-result-limit.test.ts": 334, - "src/relay/fs-handler-readdir-fallback.test.ts": 88, - "src/relay/fs-handler-ripgrep-fallback.test.ts": 260, - "src/relay/fs-handler-stream.test.ts": 660, - "src/relay/fs-handler.test.ts": 805, - "src/relay/fs-list-files-cancel.integration.test.ts": 320, - "src/relay/fs-list-files-large-response.integration.test.ts": 596, - "src/relay/fs-list-files-scan-coordinator.test.ts": 42, - "src/relay/fs-path-metadata-requests.test.ts": 93, - "src/relay/fs-path-metadata-symlink-concurrency.test.ts": 79, - "src/relay/fs-search-cancellation.test.ts": 399, - "src/relay/fs-search-diagnosis-cancellation.test.ts": 165, - "src/relay/fs-search-line-fragments.test.ts": 204, - "src/relay/fs-stream-pty-echo-backpressure.integration.test.ts": 547, - "src/relay/git-branch-delete-refusal-parity.test.ts": 177, - "src/relay/git-exec-validator.test.ts": 41, - "src/relay/git-handler-blob-readers.test.ts": 45, - "src/relay/git-handler-branch-cleanup.test.ts": 95, - "src/relay/git-handler-branch-compare.test.ts": 98, - "src/relay/git-handler-branch-diff-equivalence.test.ts": 1189, - "src/relay/git-handler-branch-diff.test.ts": 348, - "src/relay/git-handler-check-ignore.test.ts": 39, - "src/relay/git-handler-diff-read-coalescing.test.ts": 448, - "src/relay/git-handler-file-diff.test.ts": 1263, - "src/relay/git-handler-fork-remote-exec.test.ts": 222, - "src/relay/git-handler-pull-reconciliation.test.ts": 1652, - "src/relay/git-handler-push-target.test.ts": 70, - "src/relay/git-handler-remote-sync.test.ts": 1894, - "src/relay/git-handler-staging.test.ts": 381, - "src/relay/git-handler-status-ops.test.ts": 101, - "src/relay/git-handler-submodule-cache-invalidation.test.ts": 181, - "src/relay/git-handler-submodule-ops.test.ts": 126, - "src/relay/git-handler-submodule-status-cancellation.test.ts": 141, - "src/relay/git-handler-termination.test.ts": 144, - "src/relay/git-handler-utils.test.ts": 25, - "src/relay/git-handler-working-tree-changes.test.ts": 1110, - "src/relay/git-handler-worktree-clean.test.ts": 41, - "src/relay/git-handler-worktree-git-capabilities.test.ts": 118, - "src/relay/git-handler-worktree-inspection.test.ts": 429, - "src/relay/git-handler-worktree-list-authority.test.ts": 110, - "src/relay/git-handler-worktree-list.test.ts": 41, - "src/relay/git-handler-worktree-ops.test.ts": 145, - "src/relay/git-handler-worktree-paths.test.ts": 63, - "src/relay/git-handler-worktree-provisioning.test.ts": 886, - "src/relay/git-handler.test.ts": 797, - "src/relay/git-porcelain-local-parity.test.ts": 314, - "src/relay/git-push-target-local-parity.test.ts": 192, - "src/relay/git-response-pty-echo-backpressure.integration.test.ts": 1021, - "src/relay/git-response-stream-ownership.test.ts": 61, - "src/relay/git-status-branch-line-total.test.ts": 362, - "src/relay/git-status-upstream-negative-cache.test.ts": 344, - "src/relay/git-stdout-stream.test.ts": 46, - "src/relay/git-working-file-read.test.ts": 58, - "src/relay/hermes-run-correlation.test.ts": 44, - "src/relay/hermes-run-history.test.ts": 85, - "src/relay/integration.test.ts": 1877, - "src/wsl-guest/legacy-relay-publication-ledger.test.ts": 36, - "src/relay/managed-hook-installer.test.ts": 117, - "src/relay/node-pty-binding-survey.test.ts": 246, - "src/relay/node-pty-unavailable-diagnosis.test.ts": 185, - "src/wsl-guest/omp-config-root.test.ts": 152, - "src/relay/omp-fresh-launch-environment.test.ts": 600, - "src/wsl-guest/plugin-overlay-env.test.ts": 60, - "src/wsl-guest/plugin-overlay.test.ts": 164, - "src/wsl-guest/plugin-source-limit.test.ts": 41, - "src/relay/port-scan-handler.test.ts": 190, - "src/wsl-guest/preflight-handler.test.ts": 97, - "src/wsl-guest/protocol-backpressure.test.ts": 87, - "src/wsl-guest/protocol-handshake.test.ts": 63, - "src/wsl-guest/protocol-json-payload.test.ts": 188, - "src/relay/pty-handler-attach-replay.test.ts": 414, - "src/relay/pty-handler-dispose-lifecycle.test.ts": 481, - "src/relay/pty-handler-grace-timer.test.ts": 346, - "src/relay/pty-handler-immediate-descendants.test.ts": 353, - "src/relay/pty-handler-inventory-process-evidence.test.ts": 324, - "src/relay/pty-handler-output-drain-differential.test.ts": 359, - "src/relay/pty-handler-output-streaming.test.ts": 1038, - "src/relay/pty-handler-ownership-attestation.test.ts": 450, - "src/relay/pty-handler-pi-owner-env.test.ts": 243, - "src/relay/pty-handler-resize-stale-pty.test.ts": 213, - "src/relay/pty-handler-retired-pane-surface.test.ts": 599, - "src/relay/pty-handler-revive.test.ts": 526, - "src/relay/pty-handler-shell-resolution.test.ts": 378, - "src/relay/pty-handler-shutdown-signals.test.ts": 373, - "src/relay/pty-handler-source-publication.test.ts": 1048, - "src/relay/pty-handler-spawn-admission.test.ts": 560, - "src/relay/pty-handler-spawn-cwd.test.ts": 328, - "src/relay/pty-handler-spawn-environment.test.ts": 555, - "src/relay/pty-handler-startup-command-delivery.test.ts": 381, - "src/relay/pty-handler-windows-child-process-evidence.test.ts": 208, - "src/relay/pty-replay-buffer-equivalence.test.ts": 356, - "src/relay/pty-shell-launch.test.ts": 229, - "src/relay/pty-shell-utils-process-cycles.test.ts": 122, - "src/relay/pty-shell-utils.test.ts": 195, - "src/relay/pty-source-credit-ledger.test.ts": 265, - "src/relay/pty-source-credit-scheduler.test.ts": 106, - "src/relay/pty-source-sent-boundaries.test.ts": 181, - "src/relay/relay-bundled-ripgrep.test.ts": 151, - "src/wsl-guest/relay-command-env.test.ts": 96, - "src/relay/relay-daemon-fatal-reap.test.ts": 1107, - "src/relay/relay-diagnostic-log.test.ts": 62, - "src/relay/relay-endpoint-credential-publication.test.ts": 2995, - "src/relay/relay-filesystem-watch-registry.test.ts": 344, - "src/relay/relay-grace-branch.test.ts": 52, - "src/relay/relay-handshake-roundtrip.test.ts": 495, - "src/wsl-guest/relay-hot-path-operation-counts.test.ts": 211, - "src/relay/relay-launch-options.test.ts": 48, - "src/relay/relay-oversized-notification-survival.test.ts": 335, - "src/relay/relay-primary-channel.test.ts": 104, - "src/relay/relay-pty-consumer-owner-displacement.test.ts": 199, - "src/relay/relay-pty-publication-admission.test.ts": 373, - "src/relay/relay-pty-source-cancellation-exit.test.ts": 238, - "src/relay/relay-pty-source-exit-publication.test.ts": 70, - "src/relay/relay-pty-source-publication.test.ts": 395, - "src/relay/relay-pty-source-recovery-completion.test.ts": 30, - "src/relay/relay-pty-source-recovery-interleavings.test.ts": 192, - "src/relay/relay-pty-source-recovery-window.test.ts": 220, - "src/relay/relay-pty-source-restore-retry.test.ts": 226, - "src/relay/relay-pty-source-send-scheduler.test.ts": 44, - "src/relay/relay-pty-source-superseded-activation.test.ts": 193, - "src/relay/relay-reconnect-listener-credential-gate.test.ts": 95, - "src/relay/relay-ripgrep-cwd-env.test.ts": 99, - "src/relay/relay-watch-root-capacity.test.ts": 208, - "src/relay/relay-watcher-event-emitter.test.ts": 673, - "src/relay/relay-watcher-frame-chunking.test.ts": 708, - "src/relay/relay-watcher-parent-removal.test.ts": 110, - "src/relay/relay-watcher-pending-setup-waiters.test.ts": 467, - "src/relay/relay-watcher-setup-wait.test.ts": 394, - "src/relay/relay-windows-path-ripgrep.test.ts": 68, - "src/relay/remote-artifact-cli-input.test.ts": 111, - "src/relay/remote-cli-env.test.ts": 31, - "src/relay/remote-cli-stdin.test.ts": 30, - "src/relay/remote-cli-timeout.test.ts": 61, - "src/relay/retired-pane-surfaces.test.ts": 29, - "src/relay/rotating-log-writer.test.ts": 68, - "src/relay/session-search-transport.test.ts": 172, - "src/relay/skill-install-handler.test.ts": 589, - "src/relay/skill-upload-multi-relay.integration.test.ts": 667, - "src/relay/ssh-pty-consumer-session-adapter.test.ts": 304, - "src/relay/ssh-pty-source-credit-adapter.test.ts": 124, - "src/relay/subprocess-tree-termination.test.ts": 41, - "src/relay/subprocess.test.ts": 26565, - "src/relay/terminal-history-wsl.test.ts": 77, - "src/relay/terminal-history.test.ts": 63, - "src/relay/windows-port-scan.test.ts": 107, - "src/relay/windows-port-scan.win32.test.ts": 71, - "src/relay/workspace-session-handler.test.ts": 87, - "src/relay/workspace-snapshot-publication.test.ts": 126, - "src/relay/workspace-space-scan-du-capacity.test.ts": 110, - "src/relay/workspace-space-scan.test.ts": 66, - "src/wsl-guest/wsl-agent-hook-relay.test.ts": 317, - "src/wsl-guest/wsl-hook-fs-bridge.test.ts": 39, - "src/wsl-guest/wsl-install-plugins-handler.test.ts": 161, - "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 1093, - "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 97, - "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 721, - "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 35, - "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 2317, - "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 65, - "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 401, - "src/renderer/src/app-shell/startup-actions-selector.test.ts": 127, - "src/renderer/src/app-shell/use-document-appearance.test.tsx": 2017, - "src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 697, - "src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 1163, - "src/renderer/src/app-startup-routing.test.ts": 76, - "src/renderer/src/assets/diff-comment-draft-card-shadow-style.test.ts": 54, - "src/renderer/src/assets/mobile-page-qr-layout.test.ts": 39, - "src/renderer/src/assets/rich-markdown-task-list-style.test.ts": 48, - "src/renderer/src/assets/terminal-container-geometry.test.ts": 28, - "src/renderer/src/assets/terminal-scrollbar-style.test.ts": 27, - "src/renderer/src/assets/theme-utility-generation.test.ts": 45, - "src/renderer/src/assets/worktree-card-active-style.test.ts": 37, - "src/renderer/src/attention/agent-attention-policy.test.ts": 60, - "src/renderer/src/components/AgentStateDot.test.ts": 245, - "src/renderer/src/components/AgentWorkingSpinner.test.tsx": 531, - "src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx": 1570, - "src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx": 2709, - "src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx": 1009, - "src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 1670, - "src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx": 1503, - "src/renderer/src/components/NewWorkspaceComposerCard.test.tsx": 3004, - "src/renderer/src/components/QuickOpen.mount-gating.test.tsx": 2554, - "src/renderer/src/components/SelectedTextCopyMenu.test.tsx": 725, - "src/renderer/src/components/StarNagCard.test.tsx": 617, - "src/renderer/src/components/StateIndicatorTooltip.test.tsx": 96, - "src/renderer/src/components/TerminalSearch.test.tsx": 663, - "src/renderer/src/components/TerminalTitlebarTabs.test.tsx": 583, - "src/renderer/src/components/TerminalWorkbenchContainer.test.tsx": 640, - "src/renderer/src/components/UpdateCard.error-card.test.tsx": 2112, - "src/renderer/src/components/UpdateCard.test.ts": 1299, - "src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx": 2306, - "src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx": 5178, - "src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx": 2600, - "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 2441, - "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx": 8351, - "src/renderer/src/components/WorktreeJumpPalette.test.tsx": 4265, - "src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts": 1835, - "src/renderer/src/components/activity/ActivityPrototypePage.test.ts": 2064, - "src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts": 2740, - "src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx": 3734, - "src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx": 1929, - "src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx": 2221, - "src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts": 2023, - "src/renderer/src/components/activity/activity-clear-completed.test.ts": 1835, - "src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts": 1527, - "src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts": 2432, - "src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts": 2802, - "src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts": 3038, - "src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts": 2280, - "src/renderer/src/components/activity/activity-portal-churn-budget.test.ts": 53, - "src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx": 2960, - "src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx": 3091, - "src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts": 47, - "src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx": 3186, - "src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts": 45, - "src/renderer/src/components/activity/activity-scope-filter.test.ts": 2628, - "src/renderer/src/components/activity/activity-terminal-portal-publication-loop.react185.test.tsx": 288, - "src/renderer/src/components/activity/activity-terminal-portal.test.tsx": 451, - "src/renderer/src/components/activity/activity-thread-actions.test.ts": 1194, - "src/renderer/src/components/activity/activity-thread-child-agent.test.ts": 1802, - "src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts": 1360, - "src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts": 3981, - "src/renderer/src/components/activity/activity-thread-hover-card.test.tsx": 2877, - "src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx": 3116, - "src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx": 2331, - "src/renderer/src/components/activity/activity-thread-presentation.test.ts": 2334, - "src/renderer/src/components/activity/activity-thread-virtual-items.test.ts": 2318, - "src/renderer/src/components/activity/dev-activity-fixture.test.ts": 45, - "src/renderer/src/components/activity/event-time-clock-refresh.test.tsx": 1277, - "src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx": 858, - "src/renderer/src/components/activity/useActivityUnreadCount.freshness.test.tsx": 1349, - "src/renderer/src/components/activity/useActivityUnreadCount.test.ts": 1568, - "src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx": 567, - "src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.test.ts": 59, - "src/renderer/src/components/agent/AgentCombobox.test.tsx": 1607, - "src/renderer/src/components/agent/AgentSettingsDialog.test.tsx": 627, - "src/renderer/src/components/agent/agent-combobox-command-state.test.ts": 38, - "src/renderer/src/components/artifacts/ArtifactCollection.test.tsx": 2261, - "src/renderer/src/components/artifacts/ArtifactPreview.test.tsx": 778, - "src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx": 842, - "src/renderer/src/components/artifacts/ArtifactsPage.test.tsx": 1366, - "src/renderer/src/components/artifacts/artifact-display-labels.test.ts": 106, - "src/renderer/src/components/artifacts/artifact-list-search.test.ts": 120, - "src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx": 6815, - "src/renderer/src/components/artifacts/artifact-publish-flow.test.ts": 77, - "src/renderer/src/components/artifacts/artifact-published-link-client.test.ts": 52, - "src/renderer/src/components/automations/AutomationDestinationField.test.tsx": 1082, - "src/renderer/src/components/automations/AutomationDetail.host.test.tsx": 647, - "src/renderer/src/components/automations/AutomationDetail.test.tsx": 780, - "src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx": 454, - "src/renderer/src/components/automations/AutomationHostBadges.test.tsx": 695, - "src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx": 1055, - "src/renderer/src/components/automations/AutomationListEmptyView.test.tsx": 798, - "src/renderer/src/components/automations/AutomationListHostGroups.test.tsx": 926, - "src/renderer/src/components/automations/AutomationListLocalRows.test.tsx": 955, - "src/renderer/src/components/automations/AutomationListSearchField.test.tsx": 511, - "src/renderer/src/components/automations/AutomationListTableHeader.test.tsx": 928, - "src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx": 892, - "src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx": 1140, - "src/renderer/src/components/automations/AutomationRunHistory.test.tsx": 1578, - "src/renderer/src/components/automations/AutomationRunsDashboardSurface.test.tsx": 623, - "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 1058, - "src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx": 1285, - "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 1456, - "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 1061, - "src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx": 1899, - "src/renderer/src/components/automations/AutomationsDetailPane.test.tsx": 2085, - "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 1538, - "src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx": 2720, - "src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx": 1339, - "src/renderer/src/components/automations/AutomationsPage.escape-precedence.test.tsx": 2204, - "src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx": 1221, - "src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx": 1235, - "src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx": 1796, - "src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx": 1594, - "src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx": 1106, - "src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx": 1789, - "src/renderer/src/components/automations/AutomationsPage.test.tsx": 3102, - "src/renderer/src/components/automations/AutomationsPageBreadcrumb.test.tsx": 708, - "src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx": 922, + "src/main/worktree-retirement-discovery-wsl.test.ts": 184, + "src/main/worktree-retirement-discovery.test.ts": 174, + "src/main/worktree-retirement-namespace.test.ts": 84, + "src/main/worktree-root-preparation.test.ts": 116, + "src/main/worktree-trash.test.ts": 138, + "src/main/wsl-approved-root-race.wsl.test.ts": 65, + "src/main/wsl-availability-missing-kernel.test.ts": 68, + "src/main/wsl-bash-command.test.ts": 47, + "src/main/wsl-distro-list-output.test.ts": 40, + "src/main/wsl-distro-list-single-flight.test.ts": 103, + "src/main/wsl-fish-history-cleanup.test.ts": 94, + "src/main/wsl-interop-spawn-directory.test.ts": 54, + "src/main/wsl-managed-cli-path-restore.test.ts": 71, + "src/main/wsl-running-distros.test.ts": 217, + "src/main/wsl-running-path-filter.test.ts": 52, + "src/main/wsl-unc-delete-symlink-repro.test.ts": 78, + "src/main/wsl-unc-delete.test.ts": 92, + "src/main/wsl-unc-delete.wsl.test.ts": 71, + "src/main/wsl.test.ts": 152, + "src/main/wsl/wsl-executable-path.win32.test.ts": 44, + "src/main/wsl/wsl-guest-environment.test.ts": 109, + "src/main/wsl/wsl-pinned-runtime.test.ts": 413, + "src/main/wsl/wsl-runner.test.ts": 86, + "src/main/wsl/wsl-runner.wsl.test.ts": 71, + "src/main/wsl/wsl-w1-w3-contract.test.ts": 79, + "src/main/zcode/hook-service.test.ts": 130, + "src/main/zcode/interactive-capability.live.test.ts": 61, + "src/main/zcode/zcode-plan-api-key-store.test.ts": 72, + "src/packages/process-host/src/bounded-output-sink.test.ts": 98, + "src/packages/process-host/src/close-process-registry.test.ts": 51, + "src/packages/process-host/src/growing-byte-buffer.test.ts": 119, + "src/packages/process-host/src/posix-lsof-probe.test.ts": 4335, + "src/packages/process-host/src/process-tree-termination.test.ts": 86, + "src/packages/process-host/src/retryable-process-exit-proof.test.ts": 53, + "src/packages/process-host/src/run-process-bytes.test.ts": 2227, + "src/packages/process-host/src/run-process-termination-failure.test.ts": 85, + "src/packages/process-host/src/run-process.test.ts": 7249, + "src/packages/process-host/src/spawn-observer.test.ts": 181, + "src/packages/process-host/src/spawn-process-stdio.test.ts": 247, + "src/packages/process-host/src/windows-command-line.test.ts": 81, + "src/preload/api/notification-sound-playback.test.ts": 52, + "src/preload/api/platform-bridge.test.ts": 71, + "src/preload/app-restart-checkpoint-routing.test.ts": 1800, + "src/preload/browser-client-page-renderer-requests.test.ts": 262, + "src/preload/browser-find-subscriptions.test.ts": 58, + "src/preload/browser-window-close.test.ts": 45, + "src/preload/close-active-tab-payload-admission.test.ts": 54, + "src/preload/doc-preview-link-interception.test.ts": 213, + "src/preload/opencode-go-credentials.test.ts": 43, + "src/preload/preload-file-drop-absence.test.ts": 293, + "src/preload/pty-snapshot-capability-ipc.test.ts": 992, + "src/preload/renderer-heap-statistics-reader.test.ts": 58, + "src/preload/renderer-process-memory-reader.test.ts": 56, + "src/preload/renderer-restart-wiring.test.ts": 84, + "src/preload/runtime-environment-subscriptions.test.ts": 73, + "src/preload/ssh-authority-forwarding.test.ts": 1025, + "src/preload/updater-package-recovery.test.ts": 867, + "src/preload/usage-provider-api.test.ts": 52, + "src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts": 1064, + "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 1261, + "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 646, + "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 455, + "src/renderer/src/app-shell/app-window-chrome.test.ts": 37, + "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 40, + "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 2213, + "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 62, + "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 171, + "src/renderer/src/app-shell/startup-actions-selector.test.ts": 78, + "src/renderer/src/app-shell/use-document-appearance.test.tsx": 1472, + "src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx": 454, + "src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx": 291, + "src/renderer/src/app-shell/use-terminal-viewer-color-publication.test.tsx": 1708, + "src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx": 338, + "src/renderer/src/app-shell/visible-review-reporting.test.ts": 1357, + "src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 228, + "src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 927, + "src/renderer/src/attention/agent-attention-policy.test.ts": 58, + "src/renderer/src/attention/notification-sources.test.ts": 1870, + "src/renderer/src/components/AgentStateDot.test.ts": 179, + "src/renderer/src/components/AgentWorkingSpinner.test.tsx": 249, + "src/renderer/src/components/ButtonKeyHint.test.tsx": 348, + "src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx": 868, + "src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx": 2230, + "src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx": 2007, + "src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx": 2572, + "src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx": 3280, + "src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx": 884, + "src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx": 3372, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx": 1056, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 1064, + "src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx": 929, + "src/renderer/src/components/NewWorkspaceComposerCard.test.tsx": 1755, + "src/renderer/src/components/QuickOpen.mount-gating.test.tsx": 1998, + "src/renderer/src/components/QuickOpen.selection.test.tsx": 720, + "src/renderer/src/components/SelectedTextCopyMenu.test.tsx": 343, + "src/renderer/src/components/StarNagCard.test.tsx": 322, + "src/renderer/src/components/StateIndicatorTooltip.test.tsx": 76, + "src/renderer/src/components/TerminalSearch.test.tsx": 518, + "src/renderer/src/components/TerminalTitlebarTabs.test.tsx": 222, + "src/renderer/src/components/TerminalWorkbenchContainer.test.tsx": 293, + "src/renderer/src/components/UpdateCard.error-card.test.tsx": 1835, + "src/renderer/src/components/UpdateCard.test.ts": 1484, + "src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx": 1751, + "src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx": 3680, + "src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx": 2974, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 3247, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx": 5538, + "src/renderer/src/components/WorktreeJumpPalette.test.tsx": 3096, + "src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts": 54, + "src/renderer/src/components/activity/ActivityPrototypePage.test.ts": 443, + "src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts": 1500, + "src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx": 2356, + "src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx": 1673, + "src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx": 3249, + "src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts": 567, + "src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts": 2446, + "src/renderer/src/components/activity/activity-clear-completed.test.ts": 406, + "src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts": 438, + "src/renderer/src/components/activity/activity-portal-churn-budget.test.ts": 47, + "src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx": 200, + "src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx": 372, + "src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts": 62, + "src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx": 303, + "src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts": 55, + "src/renderer/src/components/activity/activity-scope-filter.test.ts": 588, + "src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx": 1691, + "src/renderer/src/components/activity/activity-terminal-portal-publication-loop.react185.test.tsx": 169, + "src/renderer/src/components/activity/activity-terminal-portal.test.tsx": 162, + "src/renderer/src/components/activity/activity-thread-actions.test.ts": 739, + "src/renderer/src/components/activity/activity-thread-context-menu.test.tsx": 2485, + "src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts": 435, + "src/renderer/src/components/activity/activity-thread-hover-card.test.tsx": 3220, + "src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx": 3450, + "src/renderer/src/components/activity/activity-thread-list-pane.multi-select.test.tsx": 3049, + "src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx": 3728, + "src/renderer/src/components/activity/dev-activity-fixture.test.ts": 62, + "src/renderer/src/components/activity/event-time-clock-refresh.test.tsx": 1553, + "src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx": 1820, + "src/renderer/src/components/activity/useActivityUnreadCount.freshness.test.tsx": 1645, + "src/renderer/src/components/activity/useActivityUnreadCount.test.ts": 1635, + "src/renderer/src/components/agent-child-row-parity.test.tsx": 641, + "src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx": 376, + "src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.test.ts": 55, + "src/renderer/src/components/agent/AgentCombobox.test.tsx": 1407, + "src/renderer/src/components/agent/AgentSettingsDialog.test.tsx": 555, + "src/renderer/src/components/agent/agent-combobox-command-state.test.ts": 45, + "src/renderer/src/components/artifacts/ArtifactCollection.test.tsx": 1101, + "src/renderer/src/components/artifacts/ArtifactPreview.test.tsx": 381, + "src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx": 616, + "src/renderer/src/components/artifacts/ArtifactsPage.test.tsx": 1568, + "src/renderer/src/components/artifacts/artifact-display-labels.test.ts": 88, + "src/renderer/src/components/artifacts/artifact-list-search.test.ts": 104, + "src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx": 3096, + "src/renderer/src/components/artifacts/artifact-publish-flow.test.ts": 125, + "src/renderer/src/components/artifacts/artifact-published-link-client.test.ts": 51, + "src/renderer/src/components/automations/AutomationDestinationField.test.tsx": 622, + "src/renderer/src/components/automations/AutomationDetail.host.test.tsx": 751, + "src/renderer/src/components/automations/AutomationDetail.test.tsx": 681, + "src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx": 318, + "src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx": 655, + "src/renderer/src/components/automations/AutomationHostBadges.test.tsx": 332, + "src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx": 379, + "src/renderer/src/components/automations/AutomationListEmptyView.test.tsx": 244, + "src/renderer/src/components/automations/AutomationListHostGroups.test.tsx": 579, + "src/renderer/src/components/automations/AutomationListLocalRows.test.tsx": 1000, + "src/renderer/src/components/automations/AutomationListSearchField.test.tsx": 345, + "src/renderer/src/components/automations/AutomationListTableHeader.test.tsx": 448, + "src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx": 476, + "src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx": 1051, + "src/renderer/src/components/automations/AutomationRunHistory.test.tsx": 1017, + "src/renderer/src/components/automations/AutomationRunsDashboardSurface.test.tsx": 351, + "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 540, + "src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx": 962, + "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 875, + "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 593, + "src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx": 2326, + "src/renderer/src/components/automations/AutomationsDetailPane.test.tsx": 2585, + "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 1015, + "src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx": 2412, + "src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx": 2046, + "src/renderer/src/components/automations/AutomationsPage.escape-precedence.test.tsx": 1344, + "src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx": 1416, + "src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx": 2091, + "src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx": 1405, + "src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx": 1871, + "src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx": 1326, + "src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx": 2040, + "src/renderer/src/components/automations/AutomationsPage.test.tsx": 2751, + "src/renderer/src/components/automations/AutomationsPageBreadcrumb.test.tsx": 304, + "src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx": 897, "src/renderer/src/components/automations/automation-authority-identity.test.ts": 54, - "src/renderer/src/components/automations/automation-captured-owner.test.ts": 107, - "src/renderer/src/components/automations/automation-create-destination.test.ts": 85, - "src/renderer/src/components/automations/automation-create-projects.test.ts": 35, - "src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts": 300, - "src/renderer/src/components/automations/automation-editor-prompt-options.test.ts": 52, - "src/renderer/src/components/automations/automation-external-target-match.test.ts": 58, - "src/renderer/src/components/automations/automation-host-cache-controller.test.ts": 139, - "src/renderer/src/components/automations/automation-host-cache-health.test.ts": 56, - "src/renderer/src/components/automations/automation-host-cache.test.ts": 83, - "src/renderer/src/components/automations/automation-host-catalog-generation.test.ts": 84, - "src/renderer/src/components/automations/automation-host-catalog-order.test.ts": 51, - "src/renderer/src/components/automations/automation-host-catalog-source.test.ts": 64, - "src/renderer/src/components/automations/automation-host-catalog.test.ts": 140, - "src/renderer/src/components/automations/automation-host-client.test.ts": 70, - "src/renderer/src/components/automations/automation-host-detail-display.test.ts": 47, - "src/renderer/src/components/automations/automation-host-diagnostics.test.ts": 141, - "src/renderer/src/components/automations/automation-host-filter-resolution.test.ts": 84, - "src/renderer/src/components/automations/automation-host-health.test.ts": 26, - "src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts": 69, - "src/renderer/src/components/automations/automation-host-invalidation.test.ts": 50, - "src/renderer/src/components/automations/automation-host-list-rows.test.ts": 140, - "src/renderer/src/components/automations/automation-host-orphan-entry.test.ts": 160, - "src/renderer/src/components/automations/automation-host-picker-groups.test.ts": 58, - "src/renderer/src/components/automations/automation-host-recovery.test.ts": 56, - "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 400, - "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 147, - "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 90, - "src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts": 1751, - "src/renderer/src/components/automations/automation-list-empty-state.test.ts": 144, - "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 43, - "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 38, - "src/renderer/src/components/automations/automation-list-last-run.test.ts": 146, - "src/renderer/src/components/automations/automation-list-row-identity.test.ts": 377, - "src/renderer/src/components/automations/automation-list-search-rows.test.ts": 318, - "src/renderer/src/components/automations/automation-list-search.test.ts": 75, - "src/renderer/src/components/automations/automation-list-view-sort.test.ts": 458, - "src/renderer/src/components/automations/automation-list-view.test.ts": 99, - "src/renderer/src/components/automations/automation-project-groups.test.ts": 62, - "src/renderer/src/components/automations/automation-run-completion-evidence.test.ts": 58, - "src/renderer/src/components/automations/automation-run-context.test.ts": 36, - "src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts": 400, - "src/renderer/src/components/automations/automation-run-open-target.test.ts": 54, - "src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts": 135, - "src/renderer/src/components/automations/automation-run-output-snapshot.test.ts": 78, - "src/renderer/src/components/automations/automation-run-view-state.test.ts": 40, - "src/renderer/src/components/automations/automation-runs-dashboard-model.test.ts": 38, - "src/renderer/src/components/automations/automation-scoped-list-client.test.ts": 127, - "src/renderer/src/components/automations/automation-setup-decision.test.ts": 1515, - "src/renderer/src/components/automations/automation-source-display.test.ts": 41, - "src/renderer/src/components/automations/automation-target-availability.test.ts": 60, - "src/renderer/src/components/automations/automation-usage-model.test.ts": 65, - "src/renderer/src/components/automations/automation-write-invalidation.test.ts": 53, - "src/renderer/src/components/automations/external-automation-display.test.ts": 66, - "src/renderer/src/components/automations/external-automation-list-entries.test.ts": 43, - "src/renderer/src/components/automations/external-automation-run-table-state.test.ts": 43, - "src/renderer/src/components/automations/external-automation-scope-gating.test.ts": 66, - "src/renderer/src/components/automations/external-automation-scope-keys.test.ts": 58, - "src/renderer/src/components/automations/external-automation-source-availability.test.ts": 55, - "src/renderer/src/components/automations/hermes-cron-output-parse.test.ts": 65, - "src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx": 810, - "src/renderer/src/components/automations/use-automation-host-catalog.test.tsx": 602, - "src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx": 556, - "src/renderer/src/components/automations/use-automation-list-search.test.tsx": 673, - "src/renderer/src/components/automations/use-automation-runs-dashboard.test.tsx": 805, - "src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx": 329, - "src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 747, - "src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx": 1424, - "src/renderer/src/components/browser-favicon.test.tsx": 631, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx": 2176, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx": 2978, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx": 2775, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 2007, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 2651, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.markup.test.tsx": 2864, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx": 1804, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 1822, - "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 986, - "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx": 69, - "src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.test.ts": 400, - "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 95, - "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx": 2934, - "src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts": 55, - "src/renderer/src/components/browser-pane/annotate/markup-screenshot-compose.test.ts": 70, - "src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts": 69, - "src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx": 1194, - "src/renderer/src/components/browser-pane/annotate/use-markup-draw-hint.test.ts": 587, - "src/renderer/src/components/browser-pane/annotate/useGrabMode.test.ts": 823, - "src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts": 590, - "src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts": 455, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx": 651, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserFind.test.tsx": 996, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts": 70, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts": 35, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts": 607, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx": 830, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion.test.ts": 39, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts": 155, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx": 529, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx": 1087, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx": 2498, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts": 30, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.test.tsx": 1863, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-import-hint-visibility.test.ts": 51, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx": 1001, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-page-zoom-indicator.test.tsx": 652, + "src/renderer/src/components/automations/automation-captured-owner.test.ts": 177, + "src/renderer/src/components/automations/automation-create-destination.test.ts": 115, + "src/renderer/src/components/automations/automation-create-projects.test.ts": 45, + "src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts": 153, + "src/renderer/src/components/automations/automation-editor-prompt-options.test.ts": 44, + "src/renderer/src/components/automations/automation-external-target-match.test.ts": 56, + "src/renderer/src/components/automations/automation-extra-agent-args-capability.test.ts": 115, + "src/renderer/src/components/automations/automation-host-cache-controller.test.ts": 185, + "src/renderer/src/components/automations/automation-host-cache-health.test.ts": 63, + "src/renderer/src/components/automations/automation-host-cache.test.ts": 71, + "src/renderer/src/components/automations/automation-host-catalog-generation.test.ts": 113, + "src/renderer/src/components/automations/automation-host-catalog-order.test.ts": 57, + "src/renderer/src/components/automations/automation-host-catalog-source.test.ts": 87, + "src/renderer/src/components/automations/automation-host-catalog.test.ts": 97, + "src/renderer/src/components/automations/automation-host-client.test.ts": 97, + "src/renderer/src/components/automations/automation-host-detail-display.test.ts": 54, + "src/renderer/src/components/automations/automation-host-diagnostics.test.ts": 206, + "src/renderer/src/components/automations/automation-host-filter-resolution.test.ts": 107, + "src/renderer/src/components/automations/automation-host-health.test.ts": 55, + "src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts": 78, + "src/renderer/src/components/automations/automation-host-invalidation.test.ts": 63, + "src/renderer/src/components/automations/automation-host-list-rows.test.ts": 181, + "src/renderer/src/components/automations/automation-host-orphan-entry.test.ts": 212, + "src/renderer/src/components/automations/automation-host-picker-groups.test.ts": 50, + "src/renderer/src/components/automations/automation-host-recovery.test.ts": 65, + "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 329, + "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 271, + "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 125, + "src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts": 1590, + "src/renderer/src/components/automations/automation-list-empty-state.test.ts": 120, + "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 52, + "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 80, + "src/renderer/src/components/automations/automation-list-last-run.test.ts": 111, + "src/renderer/src/components/automations/automation-list-row-identity.test.ts": 513, + "src/renderer/src/components/automations/automation-list-search-rows.test.ts": 483, + "src/renderer/src/components/automations/automation-list-search.test.ts": 78, + "src/renderer/src/components/automations/automation-list-view-sort.test.ts": 447, + "src/renderer/src/components/automations/automation-list-view.test.ts": 127, + "src/renderer/src/components/automations/automation-project-groups.test.ts": 57, + "src/renderer/src/components/automations/automation-run-completion-evidence.test.ts": 425, + "src/renderer/src/components/automations/automation-run-context.test.ts": 55, + "src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts": 383, + "src/renderer/src/components/automations/automation-run-open-target.test.ts": 319, + "src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts": 121, + "src/renderer/src/components/automations/automation-run-output-snapshot.test.ts": 101, + "src/renderer/src/components/automations/automation-run-view-state.test.ts": 55, + "src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts": 425, + "src/renderer/src/components/automations/automation-runs-dashboard-model.test.ts": 52, + "src/renderer/src/components/automations/automation-scoped-list-client.test.ts": 188, + "src/renderer/src/components/automations/automation-setup-decision.test.ts": 1440, + "src/renderer/src/components/automations/automation-source-display.test.ts": 52, + "src/renderer/src/components/automations/automation-target-availability.test.ts": 77, + "src/renderer/src/components/automations/automation-usage-model.test.ts": 53, + "src/renderer/src/components/automations/automation-write-invalidation.test.ts": 52, + "src/renderer/src/components/automations/external-automation-display.test.ts": 67, + "src/renderer/src/components/automations/external-automation-list-entries.test.ts": 53, + "src/renderer/src/components/automations/external-automation-run-table-state.test.ts": 50, + "src/renderer/src/components/automations/external-automation-scope-gating.test.ts": 68, + "src/renderer/src/components/automations/external-automation-scope-keys.test.ts": 76, + "src/renderer/src/components/automations/external-automation-source-availability.test.ts": 56, + "src/renderer/src/components/automations/hermes-cron-output-parse.test.ts": 75, + "src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx": 305, + "src/renderer/src/components/automations/use-automation-host-catalog.test.tsx": 341, + "src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx": 294, + "src/renderer/src/components/automations/use-automation-list-search.test.tsx": 598, + "src/renderer/src/components/automations/use-automation-runs-dashboard-payload.test.tsx": 1210, + "src/renderer/src/components/automations/use-automation-runs-dashboard.test.tsx": 631, + "src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx": 227, + "src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 434, + "src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx": 886, + "src/renderer/src/components/browser-favicon.test.tsx": 383, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx": 2452, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx": 2788, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx": 2232, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 2135, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 2806, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.markup.test.tsx": 2693, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx": 2474, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 2380, + "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 384, + "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx": 59, + "src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.test.ts": 150, + "src/renderer/src/components/browser-pane/annotate/MarkupOverlay.test.tsx": 606, + "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 105, + "src/renderer/src/components/browser-pane/annotate/browser-grab-toast-owner-cleanup.test.tsx": 921, + "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx": 2518, + "src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts": 52, + "src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts": 69, + "src/renderer/src/components/browser-pane/annotate/markup-gesture.test.ts": 110, + "src/renderer/src/components/browser-pane/annotate/markup-screenshot-compose.test.ts": 78, + "src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.test.ts": 57, + "src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts": 55, + "src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx": 883, + "src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx": 1134, + "src/renderer/src/components/browser-pane/annotate/use-markup-draw-hint.test.ts": 281, + "src/renderer/src/components/browser-pane/annotate/useGrabMode.test.ts": 497, + "src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts": 290, + "src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts": 246, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx": 545, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserFind.test.tsx": 413, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts": 404, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx": 1274, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion.test.ts": 50, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts": 348, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx": 350, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx": 456, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx": 1800, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts": 38, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.test.tsx": 2010, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-import-hint-visibility.test.ts": 64, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx": 421, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-page-zoom-indicator.test.tsx": 276, "src/renderer/src/components/browser-pane/assemble-chrome/browser-pane-page-selection.test.ts": 48, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx": 11725, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 2366, - "src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts": 29, - "src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx": 2237, - "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx": 439, - "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx": 2365, - "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.test.tsx": 1627, - "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 302, - "src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts": 39, - "src/renderer/src/components/browser-pane/browser-client-page-position-driver.test.ts": 369, - "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts": 502, - "src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts": 538, - "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts": 441, - "src/renderer/src/components/browser-pane/browser-download-destination-toast.test.ts": 47, - "src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts": 150, - "src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts": 197, - "src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts": 1993, - "src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts": 31, - "src/renderer/src/components/browser-pane/describe-page/browser-overlay-shortcut-target.test.ts": 1253, - "src/renderer/src/components/browser-pane/describe-page/browser-page-url-display.test.ts": 1564, - "src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts": 44, - "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 32, - "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 304, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 1550, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts": 1555, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.test.ts": 1611, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts": 743, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.test.ts": 1570, - "src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts": 483, - "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 592, - "src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts": 875, - "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 829, - "src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts": 35, - "src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts": 994, - "src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts": 389, - "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts": 554, - "src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts": 316, - "src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts": 83, - "src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts": 314, - "src/renderer/src/components/browser-pane/host-guest/browser-worktree-surface-paintability.test.ts": 45, - "src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx": 2091, - "src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts": 517, - "src/renderer/src/components/browser-pane/host-guest/use-browser-page-viewport-scroll-reporting.test.tsx": 605, - "src/renderer/src/components/browser-pane/host-guest/use-webview-drag-passthrough-active.test.tsx": 365, - "src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts": 73, - "src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts": 206, - "src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts": 59, - "src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx": 670, - "src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts": 114, - "src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts": 39, - "src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts": 60, - "src/renderer/src/components/browser-pane/navigate/browser-webview-load-failure.test.ts": 143, - "src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts": 1983, - "src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts": 40, - "src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx": 2074, - "src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx": 2926, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts": 30, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts": 51, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts": 32, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx": 2538, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx": 1822, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts": 97, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts": 349, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts": 60, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts": 104, - "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts": 2231, - "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts": 542, - "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx": 3716, - "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx": 3550, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts": 365, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts": 52, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts": 168, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts": 433, - "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx": 513, - "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts": 414, - "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx": 390, - "src/renderer/src/components/browser-webauthn-account-dialog.test.tsx": 813, - "src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts": 57, - "src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx": 467, - "src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts": 33, - "src/renderer/src/components/cmd-j/palette-filter-options.test.ts": 107, - "src/renderer/src/components/cmd-j/palette-filter.test.ts": 73, - "src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts": 491, - "src/renderer/src/components/cmd-j/palette-host-badge.test.ts": 126, - "src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts": 45, - "src/renderer/src/components/cmd-j/palette-live-status.test.tsx": 1777, - "src/renderer/src/components/cmd-j/palette-query-tokens.test.ts": 65, - "src/renderer/src/components/cmd-j/palette-results.test.ts": 271, - "src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts": 53, - "src/renderer/src/components/cmd-j/palette-session-age.test.ts": 69, - "src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts": 150, - "src/renderer/src/components/cmd-j/quick-action-context.test.ts": 1653, - "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 104, - "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 41, - "src/renderer/src/components/codex-restart-chip.test.tsx": 2063, - "src/renderer/src/components/comment-code-context-state.test.ts": 34, - "src/renderer/src/components/comment-reply-target-state.test.ts": 51, - "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 1586, - "src/renderer/src/components/confirmation-dialog.test.tsx": 1122, - "src/renderer/src/components/confirmation-skip-preference.test.ts": 157, - "src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx": 1677, - "src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts": 1553, - "src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx": 2477, - "src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx": 2265, - "src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx": 4465, - "src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx": 1925, - "src/renderer/src/components/contextual-tours/contextual-tour-floating-position.test.ts": 2237, - "src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts": 52, - "src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts": 2147, - "src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts": 55, - "src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts": 1567, - "src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts": 1611, - "src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts": 1626, - "src/renderer/src/components/crash-report/CrashReportDialogSurface.overflow.test.tsx": 1063, - "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 99, - "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 469, - "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 1770, - "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 1455, - "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 1261, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 2804, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 1099, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx": 2141, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx": 1461, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx": 2518, - "src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 824, - "src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 406, - "src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 45, - "src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 42, - "src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 467, - "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 611, - "src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts": 450, - "src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts": 1921, - "src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 391, - "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 54, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx": 16057, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 1906, + "src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx": 1675, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx": 319, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx": 1619, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.test.tsx": 1680, + "src/renderer/src/components/browser-pane/browser-client-page-attachment-scan-budget.test.ts": 382, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 317, + "src/renderer/src/components/browser-pane/browser-client-page-position-driver.test.ts": 398, + "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts": 333, + "src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts": 394, + "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts": 435, + "src/renderer/src/components/browser-pane/browser-download-destination-toast.test.ts": 53, + "src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts": 287, + "src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx": 359, + "src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts": 119, + "src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts": 1571, + "src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts": 48, + "src/renderer/src/components/browser-pane/describe-page/browser-overlay-shortcut-target.test.ts": 1332, + "src/renderer/src/components/browser-pane/describe-page/browser-page-url-display.test.ts": 1382, + "src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts": 42, + "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 52, + "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 149, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 1390, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts": 1745, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.test.ts": 2307, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.test.ts": 2078, + "src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts": 151, + "src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts": 1878, + "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 553, + "src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts": 1737, + "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 514, + "src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts": 48, + "src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts": 684, + "src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts": 179, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts": 397, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts": 388, + "src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts": 74, + "src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts": 155, + "src/renderer/src/components/browser-pane/host-guest/browser-worktree-surface-paintability.test.ts": 55, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx": 2135, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts": 316, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-viewport-scroll-reporting.test.tsx": 243, + "src/renderer/src/components/browser-pane/host-guest/use-webview-drag-passthrough-active.test.tsx": 246, + "src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts": 107, + "src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts": 319, + "src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts": 147, + "src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx": 808, + "src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts": 101, + "src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts": 64, + "src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts": 54, + "src/renderer/src/components/browser-pane/navigate/browser-webview-load-failure.test.ts": 290, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts": 1762, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts": 48, + "src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts": 510, + "src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx": 1699, + "src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx": 1593, + "src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx": 2547, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts": 49, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts": 41, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts": 49, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx": 2342, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx": 2187, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-service-failure.test.ts": 390, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts": 132, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts": 409, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts": 69, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts": 80, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts": 1962, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts": 338, + "src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx": 1836, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx": 3852, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx": 3045, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts": 280, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts": 62, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts": 146, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts": 154, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx": 272, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts": 191, + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx": 210, + "src/renderer/src/components/browser-webauthn-account-dialog.test.tsx": 541, + "src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx": 191, + "src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts": 49, + "src/renderer/src/components/cmd-j/palette-filter-options.test.ts": 144, + "src/renderer/src/components/cmd-j/palette-filter.test.ts": 92, + "src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts": 162, + "src/renderer/src/components/cmd-j/palette-host-badge.test.ts": 120, + "src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts": 53, + "src/renderer/src/components/cmd-j/palette-live-status.test.tsx": 1819, + "src/renderer/src/components/cmd-j/palette-query-tokens.test.ts": 66, + "src/renderer/src/components/cmd-j/palette-results.test.ts": 266, + "src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts": 79, + "src/renderer/src/components/cmd-j/palette-session-age.test.ts": 79, + "src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts": 123, + "src/renderer/src/components/cmd-j/quick-action-context.test.ts": 1708, + "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 163, + "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 58, + "src/renderer/src/components/codex-restart-chip.test.tsx": 1738, + "src/renderer/src/components/comment-code-context-state.test.ts": 56, + "src/renderer/src/components/comment-reply-target-state.test.ts": 54, + "src/renderer/src/components/confirmation-dialog.test.tsx": 833, + "src/renderer/src/components/confirmation-skip-preference.test.ts": 155, + "src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx": 839, + "src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts": 1374, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx": 2051, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx": 1496, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx": 4049, + "src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx": 1962, + "src/renderer/src/components/contextual-tours/contextual-tour-floating-position.test.ts": 2054, + "src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts": 69, + "src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts": 1941, + "src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts": 68, + "src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts": 1339, + "src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts": 1428, + "src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts": 1400, + "src/renderer/src/components/crash-report/CrashReportDialogSurface.overflow.test.tsx": 544, + "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 103, + "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 301, + "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 1462, + "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 630, + "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 732, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 2011, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx": 2133, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 1271, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx": 1908, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx": 2148, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx": 2784, + "src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 470, + "src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 323, + "src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 56, + "src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 67, + "src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 275, + "src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts": 189, + "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 439, + "src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts": 155, + "src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts": 1585, + "src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 175, + "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 58, + "src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.test.ts": 107, "src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts": 77, - "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 381, - "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 2133, - "src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 1129, - "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 1861, - "src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts": 46, - "src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts": 56, - "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 117, - "src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts": 34, - "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.allocation.test.ts": 192, - "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts": 213, - "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts": 129, - "src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts": 1728, - "src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts": 1618, - "src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts": 907, - "src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts": 1818, - "src/renderer/src/components/dashboard/dashboard-card-context.test.ts": 60, - "src/renderer/src/components/dashboard/dashboard-card-labels.test.ts": 43, - "src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts": 1383, - "src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts": 75, - "src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts": 53, - "src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts": 90, - "src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts": 218, - "src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx": 594, - "src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 824, - "src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 2326, - "src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 1992, - "src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 971, - "src/renderer/src/components/dictation/DictationIndicator.localization.test.ts": 394, - "src/renderer/src/components/dictation/DictationIndicator.test.tsx": 761, - "src/renderer/src/components/dictation/dictation-audio-meter.test.ts": 37, - "src/renderer/src/components/dictation/dictation-final-segments.test.ts": 53, - "src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 535, - "src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 356, - "src/renderer/src/components/dictation/dictation-stopped-sessions.test.ts": 39, - "src/renderer/src/components/dictation/microphone-devices.test.ts": 225, - "src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx": 436, - "src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx": 833, - "src/renderer/src/components/diff-comments/DiffCommentDraftCard.test.tsx": 2221, - "src/renderer/src/components/diff-comments/diff-comment-draft-zone.test.tsx": 2636, - "src/renderer/src/components/diff-comments/diff-comment-drag-auto-scroll.test.ts": 35, - "src/renderer/src/components/diff-comments/diff-comment-line-range.test.ts": 45, - "src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx": 1653, - "src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts": 51, - "src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts": 4994, - "src/renderer/src/components/diff-comments/diff-comment-zone-mouse-events.test.ts": 75, - "src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx": 2302, - "src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx": 10560, - "src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx": 9340, - "src/renderer/src/components/editor/ChangesModeView.test.tsx": 732, - "src/renderer/src/components/editor/CheckRunCopyButton.test.tsx": 913, - "src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx": 2524, - "src/renderer/src/components/editor/ConflictComponents.test.tsx": 456, - "src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx": 2582, - "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx": 2668, - "src/renderer/src/components/editor/EditorContent.test.tsx": 2479, - "src/renderer/src/components/editor/EditorEditFileSurface.monaco-containment.test.tsx": 2094, - "src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx": 931, - "src/renderer/src/components/editor/EditorPanel.markdown-classification-memoization.test.tsx": 3127, - "src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 1141, - "src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx": 3424, - "src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx": 234, - "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 157, - "src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx": 261, - "src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx": 671, - "src/renderer/src/components/editor/ImageViewer.test.tsx": 132, - "src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx": 1349, - "src/renderer/src/components/editor/IpynbRendering.test.tsx": 2742, - "src/renderer/src/components/editor/LargeDiffLoadPrompt.test.tsx": 657, - "src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx": 1262, - "src/renderer/src/components/editor/MarkdownPreview.test.ts": 2737, - "src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx": 1364, - "src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx": 579, - "src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx": 1063, - "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx": 6383, - "src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx": 2152, - "src/renderer/src/components/editor/NotesSendMenu.test.tsx": 302, - "src/renderer/src/components/editor/PdfFind.test.tsx": 881, - "src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx": 156, - "src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 640, - "src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx": 1046, - "src/renderer/src/components/editor/RichMarkdownLinkBubble.test.ts": 437, - "src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx": 1007, - "src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx": 326, - "src/renderer/src/components/editor/RichMarkdownTableControls.test.tsx": 2235, - "src/renderer/src/components/editor/check-annotation-open.test.ts": 84, - "src/renderer/src/components/editor/check-annotation-path.test.ts": 39, - "src/renderer/src/components/editor/check-job-step-status.test.ts": 34, - "src/renderer/src/components/editor/check-run-clipboard-text.test.ts": 42, - "src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts": 542, - "src/renderer/src/components/editor/check-run-details-tab.test.ts": 33, - "src/renderer/src/components/editor/closed-editor-model-lifetime.test.ts": 2002, - "src/renderer/src/components/editor/closed-editor-model-reentrancy.test.ts": 7076, - "src/renderer/src/components/editor/closed-editor-model-registry.test.ts": 8037, - "src/renderer/src/components/editor/closed-editor-model-shell.test.tsx": 4841, - "src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts": 55, - "src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts": 219, - "src/renderer/src/components/editor/closed-editor-view-state-retention.test.ts": 4770, - "src/renderer/src/components/editor/combined-diff-on-demand-load.test.ts": 49, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-collapsed-work.test.tsx": 780, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx": 1657, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx": 501, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-windowing.test.tsx": 970, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts": 86, - "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx": 368, - "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.test.ts": 591, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts": 46, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts": 53, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts": 1433, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts": 38, - "src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.test.tsx": 560, - "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 50, + "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 290, + "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 2233, + "src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 595, + "src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx": 1822, + "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 1741, + "src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts": 48, + "src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts": 58, + "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 134, + "src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts": 52, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.allocation.test.ts": 381, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts": 230, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts": 296, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts": 1527, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts": 1528, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts": 1542, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts": 1528, + "src/renderer/src/components/dashboard/dashboard-card-context.test.ts": 76, + "src/renderer/src/components/dashboard/dashboard-card-labels.test.ts": 77, + "src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts": 1338, + "src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts": 86, + "src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts": 60, + "src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts": 56, + "src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts": 106, + "src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts": 346, + "src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx": 273, + "src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 1283, + "src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 1818, + "src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 1671, + "src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 1484, + "src/renderer/src/components/dictation/DictationIndicator.localization.test.ts": 257, + "src/renderer/src/components/dictation/DictationIndicator.test.tsx": 461, + "src/renderer/src/components/dictation/dictation-audio-meter.test.ts": 44, + "src/renderer/src/components/dictation/dictation-final-segments.test.ts": 48, + "src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 337, + "src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 305, + "src/renderer/src/components/dictation/dictation-stopped-sessions.test.ts": 54, + "src/renderer/src/components/dictation/microphone-devices.test.ts": 309, + "src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx": 261, + "src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx": 460, + "src/renderer/src/components/diff-comments/DiffCommentDraftCard.test.tsx": 2015, + "src/renderer/src/components/diff-comments/diff-comment-draft-zone.test.tsx": 2153, + "src/renderer/src/components/diff-comments/diff-comment-drag-auto-scroll.test.ts": 47, + "src/renderer/src/components/diff-comments/diff-comment-line-range.test.ts": 61, + "src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx": 1638, + "src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts": 52, + "src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts": 9586, + "src/renderer/src/components/diff-comments/diff-comment-zone-mouse-events.test.ts": 54, + "src/renderer/src/components/diff-comments/use-visible-worktree-diff-comments.test.ts": 263, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx": 2092, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx": 8367, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx": 5475, + "src/renderer/src/components/editor/ChangesModeView.test.tsx": 345, + "src/renderer/src/components/editor/CheckRunCopyButton.test.tsx": 362, + "src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx": 1906, + "src/renderer/src/components/editor/ConflictComponents.test.tsx": 213, + "src/renderer/src/components/editor/DiffSectionBody.word-wrap-lifecycle.test.tsx": 405, + "src/renderer/src/components/editor/DiffViewer.word-wrap-lifecycle.test.tsx": 376, + "src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx": 1799, + "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx": 2378, + "src/renderer/src/components/editor/EditorContent.test.tsx": 2816, + "src/renderer/src/components/editor/EditorEditFileSurface.monaco-containment.test.tsx": 1937, + "src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx": 388, + "src/renderer/src/components/editor/EditorPanel.markdown-classification-memoization.test.tsx": 3015, + "src/renderer/src/components/editor/EditorPanel.markdown-review-tools-setting.test.tsx": 2316, + "src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 1367, + "src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx": 2121, + "src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx": 177, + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 193, + "src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx": 521, + "src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx": 719, + "src/renderer/src/components/editor/ImageViewer.test.tsx": 148, + "src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx": 1507, + "src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx": 676, + "src/renderer/src/components/editor/IpynbRendering.test.tsx": 2202, + "src/renderer/src/components/editor/LargeDiffLoadPrompt.test.tsx": 338, + "src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx": 1100, + "src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx": 1077, + "src/renderer/src/components/editor/MarkdownPreview.test.ts": 2492, + "src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx": 967, + "src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx": 342, + "src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx": 1650, + "src/renderer/src/components/editor/MediaViewer.test.tsx": 221, + "src/renderer/src/components/editor/MermaidBlock.test.tsx": 522, + "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx": 5776, + "src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx": 6247, + "src/renderer/src/components/editor/MonacoEditor.owner-view-state.test.tsx": 3008, + "src/renderer/src/components/editor/MonacoEditor.system-theme.test.tsx": 2679, + "src/renderer/src/components/editor/NotesSendMenu.test.tsx": 212, + "src/renderer/src/components/editor/PdfFind.test.tsx": 582, + "src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx": 215, + "src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 344, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx": 375, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.test.ts": 151, + "src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx": 423, + "src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx": 168, + "src/renderer/src/components/editor/RichMarkdownTableControls.test.tsx": 2455, + "src/renderer/src/components/editor/check-annotation-open.test.ts": 110, + "src/renderer/src/components/editor/check-annotation-path.test.ts": 59, + "src/renderer/src/components/editor/check-job-step-status.test.ts": 49, + "src/renderer/src/components/editor/check-run-clipboard-text.test.ts": 48, + "src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts": 549, + "src/renderer/src/components/editor/check-run-details-tab.test.ts": 43, + "src/renderer/src/components/editor/closed-editor-model-lifetime.test.ts": 5487, + "src/renderer/src/components/editor/closed-editor-model-registry.test.ts": 12691, + "src/renderer/src/components/editor/closed-editor-model-shell.test.tsx": 3197, + "src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts": 51, + "src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts": 111, + "src/renderer/src/components/editor/closed-editor-view-state-retention.test.ts": 5815, + "src/renderer/src/components/editor/combined-diff-on-demand-load.test.ts": 60, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-collapsed-work.test.tsx": 693, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx": 1027, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx": 287, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-windowing.test.tsx": 775, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts": 71, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx": 273, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.test.ts": 255, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts": 49, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts": 55, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts": 1148, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts": 58, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-stale-analysis-work-budget.test.tsx": 769, + "src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.test.tsx": 731, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 71, "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts": 55, - "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-scaling.test.tsx": 602, - "src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx": 664, - "src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts": 48, - "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 552, - "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 59, - "src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 2238, - "src/renderer/src/components/editor/csv/csv-parse.test.ts": 122, - "src/renderer/src/components/editor/details-markdown-html.test.ts": 99, - "src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 35, - "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 48, - "src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.test.ts": 484, - "src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts": 30, - "src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts": 43, - "src/renderer/src/components/editor/diff-line-stats.test.ts": 56, - "src/renderer/src/components/editor/diff-model-swap-view-state.test.ts": 524, - "src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts": 67, - "src/renderer/src/components/editor/diff-navigation-context.test.tsx": 1956, - "src/renderer/src/components/editor/diff-section-comment-submit.test.ts": 114, - "src/renderer/src/components/editor/diff-section-layout.test.ts": 68, - "src/renderer/src/components/editor/diff-section-preview.test.ts": 1595, - "src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts": 112, - "src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts": 574, - "src/renderer/src/components/editor/editor-autosave-controller.test.ts": 500, - "src/renderer/src/components/editor/editor-autosave.test.ts": 152, - "src/renderer/src/components/editor/editor-cmd-save-target.test.ts": 225, - "src/renderer/src/components/editor/editor-content-dirty-state.test.ts": 62, - "src/renderer/src/components/editor/editor-external-watch-path-index.test.ts": 1558, - "src/renderer/src/components/editor/editor-file-save-attempt.test.ts": 70, - "src/renderer/src/components/editor/editor-header-file-rename.test.ts": 608, - "src/renderer/src/components/editor/editor-header.test.ts": 37, - "src/renderer/src/components/editor/editor-labels.test.ts": 37, - "src/renderer/src/components/editor/editor-model-uri.test.ts": 127, - "src/renderer/src/components/editor/editor-panel-diff-reload.test.ts": 26, - "src/renderer/src/components/editor/editor-panel-draft-selector.test.ts": 38, - "src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts": 63, - "src/renderer/src/components/editor/editor-panel-render-model.test.ts": 2036, - "src/renderer/src/components/editor/editor-path-display.test.ts": 38, - "src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 60, - "src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts": 445, - "src/renderer/src/components/editor/editor-self-write-registry.test.ts": 69, - "src/renderer/src/components/editor/editor-shortcuts.test.ts": 526, - "src/renderer/src/components/editor/export-active-markdown.test.ts": 396, - "src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts": 46, - "src/renderer/src/components/editor/image-viewer-zoom.test.ts": 55, - "src/renderer/src/components/editor/ipynb-ansi.test.ts": 54, - "src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts": 121, - "src/renderer/src/components/editor/ipynb-kernel-outputs.test.ts": 33, - "src/renderer/src/components/editor/ipynb-kernel-session.test.ts": 183, - "src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts": 54, - "src/renderer/src/components/editor/ipynb-parse.test.ts": 77, - "src/renderer/src/components/editor/large-diff-render-limit.test.ts": 171, - "src/renderer/src/components/editor/line-copy-path.test.ts": 34, - "src/renderer/src/components/editor/local-log-tail-decoder.test.ts": 47, - "src/renderer/src/components/editor/markdown-artifact-upload.test.ts": 64, - "src/renderer/src/components/editor/markdown-dirty-state.test.ts": 317, - "src/renderer/src/components/editor/markdown-doc-completions.test.ts": 57, - "src/renderer/src/components/editor/markdown-doc-links.test.ts": 173, - "src/renderer/src/components/editor/markdown-document-list-request.test.ts": 308, - "src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts": 1467, - "src/renderer/src/components/editor/markdown-export-extract.test.ts": 606, - "src/renderer/src/components/editor/markdown-export-html.test.ts": 47, - "src/renderer/src/components/editor/markdown-frontmatter.test.ts": 49, - "src/renderer/src/components/editor/markdown-heading-slug.test.ts": 48, - "src/renderer/src/components/editor/markdown-internal-links.test.ts": 54, - "src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts": 458, - "src/renderer/src/components/editor/markdown-preview-controls.test.ts": 45, - "src/renderer/src/components/editor/markdown-preview-links.test.ts": 74, - "src/renderer/src/components/editor/markdown-preview-local-images.test.ts": 458, - "src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx": 408, - "src/renderer/src/components/editor/markdown-preview-search.test.ts": 87, - "src/renderer/src/components/editor/markdown-preview-url-transform.test.ts": 151, - "src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts": 64, - "src/renderer/src/components/editor/markdown-render-mode.test.ts": 53, - "src/renderer/src/components/editor/markdown-rich-comment-scan.test.ts": 191, - "src/renderer/src/components/editor/markdown-rich-line-scan.test.ts": 1513, - "src/renderer/src/components/editor/markdown-rich-mode-eligibility-cache.test.ts": 1351, - "src/renderer/src/components/editor/markdown-rich-mode.test.ts": 2072, - "src/renderer/src/components/editor/markdown-rich-size-limit.test.ts": 117, - "src/renderer/src/components/editor/markdown-round-trip.test.ts": 2234, - "src/renderer/src/components/editor/markdown-scanner-boundaries.test.ts": 121, - "src/renderer/src/components/editor/markdown-table-of-contents.test.ts": 275, - "src/renderer/src/components/editor/markdown-toc-collapse-state.test.ts": 56, - "src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts": 229, - "src/renderer/src/components/editor/markdown-tokenizer-start.test.ts": 80, - "src/renderer/src/components/editor/mermaid-config.test.ts": 27, - "src/renderer/src/components/editor/monaco-auto-height.test.ts": 97, - "src/renderer/src/components/editor/monaco-codebase-search.test.ts": 71, - "src/renderer/src/components/editor/monaco-conflict-decorations.test.ts": 83, - "src/renderer/src/components/editor/monaco-content-sync.test.ts": 53, - "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts": 1055, - "src/renderer/src/components/editor/monaco-context-menu-paste.test.ts": 547, - "src/renderer/src/components/editor/monaco-cursor-line-lifetime.test.ts": 552, - "src/renderer/src/components/editor/monaco-find-options.test.ts": 36, - "src/renderer/src/components/editor/monaco-find-widget.test.ts": 492, - "src/renderer/src/components/editor/monaco-large-text-paste.test.ts": 282, - "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts": 169, - "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts": 74, - "src/renderer/src/components/editor/monaco-markdown-selection-annotation.test.ts": 41, - "src/renderer/src/components/editor/monaco-programmatic-sync.test.ts": 35, - "src/renderer/src/components/editor/monaco-reveal-range.test.ts": 53, - "src/renderer/src/components/editor/monaco-view-state-persistence.test.ts": 31, - "src/renderer/src/components/editor/pdf-js-document-options.test.ts": 34, - "src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts": 42, - "src/renderer/src/components/editor/pdf-scale-preference.test.ts": 67, - "src/renderer/src/components/editor/pdf-view-position.test.ts": 51, - "src/renderer/src/components/editor/pending-editor-focus-request.test.ts": 23, - "src/renderer/src/components/editor/position-stable-node-view-update.test.ts": 49, - "src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts": 88, - "src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts": 302, - "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts": 2399, - "src/renderer/src/components/editor/rich-markdown-auto-focus.test.ts": 425, - "src/renderer/src/components/editor/rich-markdown-block-source-work.test.ts": 2647, - "src/renderer/src/components/editor/rich-markdown-code-block-languages.test.ts": 135, - "src/renderer/src/components/editor/rich-markdown-commands.test.ts": 1938, - "src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts": 2415, - "src/renderer/src/components/editor/rich-markdown-cut.test.ts": 794, - "src/renderer/src/components/editor/rich-markdown-details-case.test.ts": 1964, - "src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts": 2040, - "src/renderer/src/components/editor/rich-markdown-doc-link.test.ts": 40, - "src/renderer/src/components/editor/rich-markdown-document-fidelity.test.ts": 2200, - "src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts": 145, - "src/renderer/src/components/editor/rich-markdown-editor-config.test.ts": 1687, - "src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts": 277, - "src/renderer/src/components/editor/rich-markdown-escape-fidelity.test.ts": 2267, - "src/renderer/src/components/editor/rich-markdown-extension.test.ts": 278, - "src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts": 2950, - "src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts": 1229, - "src/renderer/src/components/editor/rich-markdown-image-insert.test.ts": 1296, - "src/renderer/src/components/editor/rich-markdown-inline-image-paragraph.test.ts": 1666, - "src/renderer/src/components/editor/rich-markdown-inline-source-tokenization.test.ts": 1619, - "src/renderer/src/components/editor/rich-markdown-key-handler.test.ts": 2540, - "src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts": 435, - "src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts": 346, - "src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts": 2061, - "src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts": 337, - "src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts": 94, - "src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts": 189, - "src/renderer/src/components/editor/rich-markdown-local-image.test.ts": 1442, - "src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts": 173, - "src/renderer/src/components/editor/rich-markdown-lowlight.test.ts": 3282, - "src/renderer/src/components/editor/rich-markdown-normalize.test.ts": 217, - "src/renderer/src/components/editor/rich-markdown-paragraph.test.ts": 72, - "src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts": 71, - "src/renderer/src/components/editor/rich-markdown-paste-image.test.ts": 290, - "src/renderer/src/components/editor/rich-markdown-range-bounds.test.ts": 77, - "src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts": 62, - "src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts": 69, - "src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts": 1569, - "src/renderer/src/components/editor/rich-markdown-review-rail-blocks.test.ts": 2656, - "src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts": 70, - "src/renderer/src/components/editor/rich-markdown-save-benchmark.test.ts": 1966, - "src/renderer/src/components/editor/rich-markdown-search-matches-cache.test.ts": 98, - "src/renderer/src/components/editor/rich-markdown-search.test.ts": 84, - "src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts": 1584, - "src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts": 59, - "src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts": 2386, - "src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts": 2323, - "src/renderer/src/components/editor/rich-markdown-table-actions.test.ts": 2946, - "src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts": 54, - "src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts": 2006, - "src/renderer/src/components/editor/rich-markdown-table.test.ts": 108, - "src/renderer/src/components/editor/rich-markdown-terminal-path-paste.test.ts": 333, - "src/renderer/src/components/editor/rich-markdown-toc-heading-target.test.ts": 261, - "src/renderer/src/components/editor/rich-markdown-trailing-paragraph.test.ts": 1766, - "src/renderer/src/components/editor/selection-copy.test.ts": 56, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-scaling.test.tsx": 681, + "src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx": 265, + "src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts": 59, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.test.ts": 47, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 740, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 68, + "src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 1135, + "src/renderer/src/components/editor/csv/CsvGrid.test.tsx": 765, + "src/renderer/src/components/editor/csv/CsvViewer.delimiter.test.tsx": 2535, + "src/renderer/src/components/editor/csv/CsvViewer.editing.test.tsx": 3037, + "src/renderer/src/components/editor/csv/csv-byte-index.test.ts": 1733, + "src/renderer/src/components/editor/csv/csv-cell-links.test.tsx": 294, + "src/renderer/src/components/editor/csv/csv-column-width-preferences.test.ts": 144, + "src/renderer/src/components/editor/csv/csv-paged-preview.test.ts": 94, + "src/renderer/src/components/editor/csv/csv-parse-budget.test.ts": 100, + "src/renderer/src/components/editor/csv/csv-parse.test.ts": 125, + "src/renderer/src/components/editor/csv/csv-pending-save.test.ts": 1349, + "src/renderer/src/components/editor/csv/csv-preview-save-safety.test.ts": 1483, + "src/renderer/src/components/editor/csv/csv-table-interactions.test.ts": 118, + "src/renderer/src/components/editor/csv/csv-text-document.test.ts": 139, + "src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx": 396, + "src/renderer/src/components/editor/details-markdown-html.test.ts": 78, + "src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 54, + "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 73, + "src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.test.ts": 155, + "src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts": 48, + "src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts": 180, + "src/renderer/src/components/editor/diff-line-stats.test.ts": 58, + "src/renderer/src/components/editor/diff-model-swap-view-state.test.ts": 145, + "src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts": 51, + "src/renderer/src/components/editor/diff-navigation-context.test.tsx": 1732, + "src/renderer/src/components/editor/diff-section-comment-submit.test.ts": 99, + "src/renderer/src/components/editor/diff-section-layout.test.ts": 87, + "src/renderer/src/components/editor/diff-section-preview.test.ts": 1498, + "src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts": 93, + "src/renderer/src/components/editor/drag-selection-guard-lifetime.test.ts": 563, + "src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts": 634, + "src/renderer/src/components/editor/editor-autosave-controller.test.ts": 652, + "src/renderer/src/components/editor/editor-autosave.test.ts": 279, + "src/renderer/src/components/editor/editor-cmd-save-target.test.ts": 258, + "src/renderer/src/components/editor/editor-content-dirty-state.test.ts": 83, + "src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts": 362, + "src/renderer/src/components/editor/editor-dropped-file-open.test.ts": 1242, + "src/renderer/src/components/editor/editor-external-watch-path-index.test.ts": 1264, + "src/renderer/src/components/editor/editor-file-save-attempt.test.ts": 57, + "src/renderer/src/components/editor/editor-header-file-rename.test.ts": 1748, + "src/renderer/src/components/editor/editor-header.test.ts": 59, + "src/renderer/src/components/editor/editor-labels.test.ts": 54, + "src/renderer/src/components/editor/editor-model-host-isolation.test.ts": 2625, + "src/renderer/src/components/editor/editor-model-owner.test.ts": 309, + "src/renderer/src/components/editor/editor-model-uri.test.ts": 131, + "src/renderer/src/components/editor/editor-panel-diff-reload.test.ts": 46, + "src/renderer/src/components/editor/editor-panel-draft-selector.test.ts": 64, + "src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts": 54, + "src/renderer/src/components/editor/editor-panel-render-model.test.ts": 1971, + "src/renderer/src/components/editor/editor-path-display.test.ts": 52, + "src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 54, + "src/renderer/src/components/editor/editor-pending-flush.test.ts": 38, + "src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts": 624, + "src/renderer/src/components/editor/editor-save-user-named-tab.test.ts": 547, + "src/renderer/src/components/editor/editor-self-write-registry.test.ts": 93, + "src/renderer/src/components/editor/editor-shortcuts.test.ts": 225, + "src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx": 668, + "src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts": 837, + "src/renderer/src/components/editor/export-active-markdown.test.ts": 128, + "src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts": 55, + "src/renderer/src/components/editor/image-viewer-zoom.test.ts": 63, + "src/renderer/src/components/editor/ipynb-ansi.test.ts": 70, + "src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts": 142, + "src/renderer/src/components/editor/ipynb-kernel-outputs.test.ts": 318, + "src/renderer/src/components/editor/ipynb-kernel-session-start-lifetime.test.ts": 113, + "src/renderer/src/components/editor/ipynb-kernel-session.test.ts": 182, + "src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts": 58, + "src/renderer/src/components/editor/ipynb-parse.test.ts": 80, + "src/renderer/src/components/editor/large-diff-render-limit.test.ts": 168, + "src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx": 2232, + "src/renderer/src/components/editor/line-copy-path.test.ts": 45, + "src/renderer/src/components/editor/local-log-tail-decoder.test.ts": 113, + "src/renderer/src/components/editor/markdown-artifact-upload.test.ts": 68, + "src/renderer/src/components/editor/markdown-dirty-state.test.ts": 274, + "src/renderer/src/components/editor/markdown-doc-completions.test.ts": 59, + "src/renderer/src/components/editor/markdown-doc-links.test.ts": 139, + "src/renderer/src/components/editor/markdown-document-list-request.test.ts": 379, + "src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts": 1527, + "src/renderer/src/components/editor/markdown-export-extract.test.ts": 218, + "src/renderer/src/components/editor/markdown-export-html.test.ts": 58, + "src/renderer/src/components/editor/markdown-frontmatter.test.ts": 53, + "src/renderer/src/components/editor/markdown-heading-slug.test.ts": 42, + "src/renderer/src/components/editor/markdown-internal-links.test.ts": 74, + "src/renderer/src/components/editor/markdown-preview-anchor-navigation.test.ts": 135, + "src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts": 177, + "src/renderer/src/components/editor/markdown-preview-controls.test.ts": 91, + "src/renderer/src/components/editor/markdown-preview-document-client.test.ts": 58, + "src/renderer/src/components/editor/markdown-preview-document-search-budget.test.ts": 357, + "src/renderer/src/components/editor/markdown-preview-document-search-cache.test.ts": 552, + "src/renderer/src/components/editor/markdown-preview-document-search-scope.test.ts": 385, + "src/renderer/src/components/editor/markdown-preview-document.test.ts": 1525, + "src/renderer/src/components/editor/markdown-preview-links.test.ts": 67, + "src/renderer/src/components/editor/markdown-preview-local-images.test.ts": 1715, + "src/renderer/src/components/editor/markdown-preview-review-frame-lifecycle.test.tsx": 325, + "src/renderer/src/components/editor/markdown-preview-row-measurements.test.ts": 167, + "src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx": 230, + "src/renderer/src/components/editor/markdown-preview-search-memory.test.ts": 171, + "src/renderer/src/components/editor/markdown-preview-search-offsets.test.ts": 518, + "src/renderer/src/components/editor/markdown-preview-search.test.ts": 79, + "src/renderer/src/components/editor/markdown-preview-table-chunks.test.ts": 6270, + "src/renderer/src/components/editor/markdown-preview-url-transform.test.ts": 95, + "src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts": 77, + "src/renderer/src/components/editor/markdown-render-mode.test.ts": 50, + "src/renderer/src/components/editor/markdown-rich-comment-scan.test.ts": 323, + "src/renderer/src/components/editor/markdown-rich-line-scan.test.ts": 1628, + "src/renderer/src/components/editor/markdown-rich-mode-eligibility-cache.test.ts": 3909, + "src/renderer/src/components/editor/markdown-rich-mode.test.ts": 2831, + "src/renderer/src/components/editor/markdown-rich-size-limit.test.ts": 340, + "src/renderer/src/components/editor/markdown-round-trip.test.ts": 2101, + "src/renderer/src/components/editor/markdown-scanner-boundaries.test.ts": 100, + "src/renderer/src/components/editor/markdown-table-of-contents.test.ts": 166, + "src/renderer/src/components/editor/markdown-toc-collapse-state.test.ts": 53, + "src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts": 105, + "src/renderer/src/components/editor/markdown-tokenizer-start.test.ts": 97, + "src/renderer/src/components/editor/mermaid-config.test.ts": 44, + "src/renderer/src/components/editor/monaco-auto-height.test.ts": 114, + "src/renderer/src/components/editor/monaco-codebase-search.test.ts": 52, + "src/renderer/src/components/editor/monaco-conflict-decorations.test.ts": 68, + "src/renderer/src/components/editor/monaco-content-sync.test.ts": 67, + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts": 1193, + "src/renderer/src/components/editor/monaco-context-menu-paste.test.ts": 208, + "src/renderer/src/components/editor/monaco-cursor-line-lifetime.test.ts": 1102, + "src/renderer/src/components/editor/monaco-find-options.test.ts": 44, + "src/renderer/src/components/editor/monaco-find-widget.test.ts": 167, + "src/renderer/src/components/editor/monaco-large-text-paste.test.ts": 208, + "src/renderer/src/components/editor/monaco-markdown-doc-completions-retention.test.ts": 64, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts": 267, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts": 82, + "src/renderer/src/components/editor/monaco-markdown-selection-annotation.test.ts": 49, + "src/renderer/src/components/editor/monaco-programmatic-sync.test.ts": 54, + "src/renderer/src/components/editor/monaco-reveal-range.test.ts": 45, + "src/renderer/src/components/editor/monaco-view-state-persistence.test.ts": 55, + "src/renderer/src/components/editor/pdf-js-document-options.test.ts": 46, + "src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts": 65, + "src/renderer/src/components/editor/pdf-scale-preference.test.ts": 71, + "src/renderer/src/components/editor/pdf-view-position.test.ts": 73, + "src/renderer/src/components/editor/pdf-viewer-failed-load-recovery.test.tsx": 2246, + "src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx": 933, + "src/renderer/src/components/editor/pdf-zoom-request.test.ts": 137, + "src/renderer/src/components/editor/pending-editor-focus-request.test.ts": 34, + "src/renderer/src/components/editor/position-stable-node-view-update.test.ts": 46, + "src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts": 82, + "src/renderer/src/components/editor/raw-markdown-html-line-start.test.ts": 62, + "src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts": 195, + "src/renderer/src/components/editor/raw-markdown-html.details-fences.test.ts": 64, + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts": 2235, + "src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx": 1466, + "src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts": 1484, + "src/renderer/src/components/editor/rich-markdown-async-image-table.test.ts": 1133, + "src/renderer/src/components/editor/rich-markdown-auto-focus.test.ts": 197, + "src/renderer/src/components/editor/rich-markdown-code-block-languages.test.ts": 92, + "src/renderer/src/components/editor/rich-markdown-commands.test.ts": 1846, + "src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts": 2049, + "src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts": 1887, + "src/renderer/src/components/editor/rich-markdown-cut.test.ts": 293, + "src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts": 3545, + "src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts": 306, + "src/renderer/src/components/editor/rich-markdown-doc-link-resolution.test.ts": 248, + "src/renderer/src/components/editor/rich-markdown-doc-link.test.ts": 51, + "src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts": 695, + "src/renderer/src/components/editor/rich-markdown-editor-config.test.ts": 1952, + "src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts": 208, + "src/renderer/src/components/editor/rich-markdown-escape-fidelity.test.ts": 1526, + "src/renderer/src/components/editor/rich-markdown-extension.test.ts": 260, + "src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts": 3213, + "src/renderer/src/components/editor/rich-markdown-image-context.test.ts": 675, + "src/renderer/src/components/editor/rich-markdown-image-cut.test.ts": 2226, + "src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts": 1165, + "src/renderer/src/components/editor/rich-markdown-image-insert.test.ts": 770, + "src/renderer/src/components/editor/rich-markdown-image-insertion-target.test.ts": 2190, + "src/renderer/src/components/editor/rich-markdown-inline-source-tokenization.test.ts": 2521, + "src/renderer/src/components/editor/rich-markdown-key-handler.test.ts": 2196, + "src/renderer/src/components/editor/rich-markdown-large-html.test.ts": 3625, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.fidelity.test.ts": 1997, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts": 2208, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts": 1824, + "src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts": 140, + "src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts": 1507, + "src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts": 150, + "src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts": 87, + "src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts": 160, + "src/renderer/src/components/editor/rich-markdown-local-image.test.ts": 1491, + "src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts": 149, + "src/renderer/src/components/editor/rich-markdown-lowlight.test.ts": 1932, + "src/renderer/src/components/editor/rich-markdown-normalize.test.ts": 138, + "src/renderer/src/components/editor/rich-markdown-paragraph.test.ts": 90, + "src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts": 74, + "src/renderer/src/components/editor/rich-markdown-paste-image.test.ts": 494, + "src/renderer/src/components/editor/rich-markdown-range-bounds.test.ts": 94, + "src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts": 109, + "src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts": 58, + "src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts": 1687, + "src/renderer/src/components/editor/rich-markdown-review-rail-blocks.test.ts": 2390, + "src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts": 74, + "src/renderer/src/components/editor/rich-markdown-save-benchmark.test.ts": 1604, + "src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts": 316, + "src/renderer/src/components/editor/rich-markdown-search-focus.test.ts": 226, + "src/renderer/src/components/editor/rich-markdown-search-matches-cache.test.ts": 116, + "src/renderer/src/components/editor/rich-markdown-search.test.ts": 103, + "src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts": 1408, + "src/renderer/src/components/editor/rich-markdown-signed-images.test.ts": 1568, + "src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts": 47, + "src/renderer/src/components/editor/rich-markdown-source-eol-allocation.test.ts": 285, + "src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts": 1937, + "src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts": 2528, + "src/renderer/src/components/editor/rich-markdown-table-actions.test.ts": 2439, + "src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts": 51, + "src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts": 1783, + "src/renderer/src/components/editor/rich-markdown-table.test.ts": 84, + "src/renderer/src/components/editor/rich-markdown-terminal-path-paste.test.ts": 150, + "src/renderer/src/components/editor/rich-markdown-toc-heading-target.test.ts": 168, + "src/renderer/src/components/editor/rich-markdown-trailing-paragraph.test.ts": 2081, + "src/renderer/src/components/editor/selection-copy.test.ts": 63, "src/renderer/src/components/editor/setup-contextual-copy.test.ts": 93, - "src/renderer/src/components/editor/untitled-file-rename-path.test.ts": 49, - "src/renderer/src/components/editor/use-monaco-editor-decorations.doc-link-refresh.test.tsx": 398, - "src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx": 1210, - "src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx": 1455, - "src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx": 2651, - "src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx": 628, - "src/renderer/src/components/editor/useEditorCmdSaveRequest.test.tsx": 494, - "src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 2597, - "src/renderer/src/components/editor/useEditorPanelExternalContentEvents.test.tsx": 567, - "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 600, - "src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx": 550, - "src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx": 556, - "src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx": 791, - "src/renderer/src/components/editor/useIpynbCellExecution.test.tsx": 768, - "src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx": 359, - "src/renderer/src/components/editor/useLinkBubble.test.tsx": 638, - "src/renderer/src/components/editor/useLocalImageSrc.test.ts": 819, - "src/renderer/src/components/editor/useLocalLogTail.test.tsx": 506, - "src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts": 674, - "src/renderer/src/components/editor/useMarkdownDocuments.test.ts": 964, - "src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx": 384, - "src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx": 589, - "src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts": 532, - "src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts": 2348, - "src/renderer/src/components/editor/useRichMarkdownReviewController.open-guard.test.ts": 673, - "src/renderer/src/components/editor/useRichMarkdownSearch.reuse.test.tsx": 500, - "src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx": 1658, - "src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx": 939, - "src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts": 32, - "src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx": 951, - "src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx": 761, - "src/renderer/src/components/emulator-pane/emulator-device-state.test.ts": 29, - "src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts": 325, - "src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts": 66, - "src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx": 754, - "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts": 60, - "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-visibility.test.ts": 37, - "src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx": 525, - "src/renderer/src/components/emulator-pane/mobile-emulator-tab-intro-visibility.test.ts": 37, - "src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx": 2088, - "src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx": 1609, - "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx": 1006, - "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx": 524, - "src/renderer/src/components/error-boundaries/react-185-bystander-attribution.test.tsx": 542, - "src/renderer/src/components/feature-interaction-writer-boundaries.test.ts": 51, - "src/renderer/src/components/feature-tips/CliSkillSetupTerminal.test.tsx": 1232, - "src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx": 812, - "src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.test.tsx": 264, - "src/renderer/src/components/feature-tips/SessionSearchTipDialog.test.tsx": 601, - "src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx": 557, - "src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx": 875, - "src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts": 52, - "src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts": 179, - "src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 218, - "src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts": 57, - "src/renderer/src/components/feature-tips/use-session-search-tip-setup.test.tsx": 702, - "src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 1578, - "src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 658, - "src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 1337, - "src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 881, - "src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx": 1030, - "src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.test.tsx": 711, + "src/renderer/src/components/editor/untitled-file-rename-path.test.ts": 48, + "src/renderer/src/components/editor/use-editor-content-change-handler.test.tsx": 1784, + "src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx": 2275, + "src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx": 1048, + "src/renderer/src/components/editor/use-markdown-preview-document-search.test.tsx": 228, + "src/renderer/src/components/editor/use-markdown-preview-document.test.tsx": 290, + "src/renderer/src/components/editor/use-markdown-preview-navigation.test.tsx": 512, + "src/renderer/src/components/editor/use-markdown-preview-search-reveal.test.tsx": 372, + "src/renderer/src/components/editor/use-monaco-editor-decorations.doc-link-refresh.test.tsx": 206, + "src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx": 1084, + "src/renderer/src/components/editor/use-pdf-viewer-find-shortcut.test.tsx": 740, + "src/renderer/src/components/editor/use-quick-open-history-visit.test.tsx": 225, + "src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx": 2188, + "src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx": 2157, + "src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx": 273, + "src/renderer/src/components/editor/useEditorCmdSaveRequest.test.tsx": 455, + "src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 1443, + "src/renderer/src/components/editor/useEditorPanelExternalContentEvents.test.tsx": 461, + "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx": 1687, + "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 1705, + "src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx": 615, + "src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx": 784, + "src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx": 579, + "src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx": 660, + "src/renderer/src/components/editor/useIpynbCellExecution.test.tsx": 431, + "src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx": 349, + "src/renderer/src/components/editor/useLinkBubble.test.tsx": 356, + "src/renderer/src/components/editor/useLocalImagePick.test.tsx": 1197, + "src/renderer/src/components/editor/useLocalImageSrc.test.ts": 587, + "src/renderer/src/components/editor/useLocalLogTail.test.tsx": 511, + "src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts": 573, + "src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx": 1885, + "src/renderer/src/components/editor/useMarkdownDocuments.test.ts": 1520, + "src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts": 438, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx": 340, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx": 265, + "src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts": 249, + "src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts": 1933, + "src/renderer/src/components/editor/useRichMarkdownReviewController.open-guard.test.ts": 418, + "src/renderer/src/components/editor/useRichMarkdownReviewData.test.ts": 302, + "src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx": 617, + "src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx": 577, + "src/renderer/src/components/editor/useRichMarkdownSearch.reuse.test.tsx": 355, + "src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx": 1817, + "src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx": 386, + "src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts": 54, + "src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx": 843, + "src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx": 577, + "src/renderer/src/components/emulator-pane/emulator-device-state.test.ts": 55, + "src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts": 198, + "src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts": 83, + "src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx": 371, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-visibility.test.ts": 46, + "src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx": 214, + "src/renderer/src/components/emulator-pane/mobile-emulator-tab-intro-visibility.test.ts": 45, + "src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx": 1737, + "src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx": 1585, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx": 289, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx": 296, + "src/renderer/src/components/error-boundaries/react-185-bystander-attribution.test.tsx": 391, + "src/renderer/src/components/feature-tips/CliSkillSetupTerminal.test.tsx": 1870, + "src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx": 325, + "src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.test.tsx": 169, + "src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.test.tsx": 1996, + "src/renderer/src/components/feature-tips/SessionSearchTipDialog.test.tsx": 374, + "src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx": 338, + "src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx": 269, + "src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts": 56, + "src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts": 269, + "src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 299, + "src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts": 66, + "src/renderer/src/components/feature-tips/use-session-search-tip-setup.test.tsx": 441, + "src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 1436, + "src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 749, + "src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 1754, + "src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 330, + "src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx": 641, + "src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.test.tsx": 383, "src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts": 62, - "src/renderer/src/components/feature-wall/feature-wall-animation-visibility.test.tsx": 729, - "src/renderer/src/components/feature-wall/feature-wall-rail-navigation.test.ts": 62, - "src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts": 233, - "src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts": 69, - "src/renderer/src/components/feature-wall/feature-wall-shortcut-labels.test.tsx": 2097, - "src/renderer/src/components/feature-wall/feature-wall-usage-tracking.test.ts": 104, - "src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts": 44, - "src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts": 69, - "src/renderer/src/components/feature-wall/use-integration-connection-status.test.ts": 1530, - "src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts": 58, - "src/renderer/src/components/file-path-cursor-tooltip.test.ts": 428, - "src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx": 379, - "src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx": 624, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx": 740, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx": 576, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx": 1034, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx": 428, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx": 904, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx": 1123, - "src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx": 152, - "src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx": 238, - "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 44, - "src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 102, - "src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 242, - "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 69, - "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 1642, - "src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts": 59, - "src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts": 1592, - "src/renderer/src/components/github-body-draft-state.test.ts": 61, - "src/renderer/src/components/github-checks-tab-state.test.ts": 51, - "src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts": 50, - "src/renderer/src/components/github-item-dialog-source-boundary.test.ts": 47, - "src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx": 647, - "src/renderer/src/components/github-link-copy-state.test.ts": 56, - "src/renderer/src/components/github-pr-merge-state.test.ts": 94, - "src/renderer/src/components/github-pr-reviewer-display.test.ts": 57, - "src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 414, - "src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 2001, - "src/renderer/src/components/github-project/ProjectViewStates.test.tsx": 757, - "src/renderer/src/components/github-project/github-project-picker-filter.test.ts": 62, - "src/renderer/src/components/github-project/group-sort.test.ts": 40, - "src/renderer/src/components/github-project/project-dialog-state.test.ts": 55, - "src/renderer/src/components/github-project/project-picker-browse-cache.test.ts": 51, - "src/renderer/src/components/github-project/project-picker-input.test.ts": 918, - "src/renderer/src/components/github-project/project-row-filtering.test.ts": 51, - "src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts": 44, - "src/renderer/src/components/github-project/project-visible-table-cache.test.ts": 49, - "src/renderer/src/components/github/PRFilterPickers.test.ts": 310, - "src/renderer/src/components/github/github-markdown-image-url.test.ts": 33, - "src/renderer/src/components/github/github-mention-option-filter.test.ts": 34, - "src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts": 74, - "src/renderer/src/components/github/github-user-avatar.test.tsx": 560, - "src/renderer/src/components/github/github-work-item-assignee-filter.test.ts": 61, - "src/renderer/src/components/github/github-work-item-label-filter.test.ts": 42, - "src/renderer/src/components/github/pr-comment-code-context.test.ts": 73, - "src/renderer/src/components/github/pr-file-content-size.test.ts": 36, - "src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts": 63, - "src/renderer/src/components/github/use-image-input.test.ts": 2130, - "src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts": 40, - "src/renderer/src/components/jira-create-adf.test.ts": 61, - "src/renderer/src/components/jira-project-picker-filter.test.ts": 40, - "src/renderer/src/components/landing-preflight-dismissal.test.ts": 67, - "src/renderer/src/components/landing-preflight-issues.test.ts": 123, - "src/renderer/src/components/landing-preflight-runtime-boundary.test.ts": 1359, - "src/renderer/src/components/landing-preflight-unverifiable-host.test.tsx": 2022, - "src/renderer/src/components/linear-api-key-dialog-state.test.ts": 51, - "src/renderer/src/components/linear-issue-attribute-filter-coverage-agreement.test.ts": 1177, - "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx": 1616, - "src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts": 64, - "src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts": 86, - "src/renderer/src/components/linear-issue-project-selector.test.tsx": 766, + "src/renderer/src/components/feature-wall/feature-wall-animation-visibility.test.tsx": 638, + "src/renderer/src/components/feature-wall/feature-wall-rail-navigation.test.ts": 57, + "src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts": 552, + "src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts": 61, + "src/renderer/src/components/feature-wall/feature-wall-shortcut-labels.test.tsx": 1718, + "src/renderer/src/components/feature-wall/feature-wall-usage-tracking.test.ts": 91, + "src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts": 85, + "src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts": 55, + "src/renderer/src/components/feature-wall/use-integration-connection-status.test.ts": 2054, + "src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts": 54, + "src/renderer/src/components/file-path-cursor-tooltip.test.ts": 177, + "src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx": 491, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx": 4024, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx": 7004, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx": 2710, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx": 4440, + "src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx": 126, + "src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx": 433, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 57, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 313, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 172, + "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 68, + "src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts": 228, + "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 1744, + "src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx": 2068, + "src/renderer/src/components/github-body-draft-state.test.ts": 54, + "src/renderer/src/components/github-checks-tab-state.test.ts": 61, + "src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx": 414, + "src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx": 330, + "src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts": 69, + "src/renderer/src/components/github-link-copy-state.test.ts": 45, + "src/renderer/src/components/github-pr-merge-state.test.ts": 99, + "src/renderer/src/components/github-pr-reviewer-display.test.ts": 61, + "src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 180, + "src/renderer/src/components/github-project/ProjectBoard.test.tsx": 554, + "src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 2087, + "src/renderer/src/components/github-project/ProjectViewStates.test.tsx": 459, + "src/renderer/src/components/github-project/github-project-picker-filter.test.ts": 67, + "src/renderer/src/components/github-project/group-sort.test.ts": 89, + "src/renderer/src/components/github-project/project-dialog-state.test.ts": 46, + "src/renderer/src/components/github-project/project-picker-browse-cache.test.ts": 52, + "src/renderer/src/components/github-project/project-picker-input.test.ts": 1461, + "src/renderer/src/components/github-project/project-row-filtering.test.ts": 68, + "src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts": 64, + "src/renderer/src/components/github-project/project-visible-table-cache.test.ts": 57, + "src/renderer/src/components/github/PRFilterPickers.test.ts": 173, + "src/renderer/src/components/github/github-markdown-image-url.test.ts": 53, + "src/renderer/src/components/github/github-mention-option-filter.test.ts": 75, + "src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts": 66, + "src/renderer/src/components/github/github-user-avatar.test.tsx": 238, + "src/renderer/src/components/github/github-work-item-assignee-filter.test.ts": 70, + "src/renderer/src/components/github/github-work-item-issue-target.test.ts": 163, + "src/renderer/src/components/github/github-work-item-label-filter.test.ts": 75, + "src/renderer/src/components/github/github-work-item-mutation-target.test.ts": 144, + "src/renderer/src/components/github/pr-comment-code-context.test.ts": 79, + "src/renderer/src/components/github/pr-file-content-size.test.ts": 57, + "src/renderer/src/components/github/use-image-input.test.ts": 2248, + "src/renderer/src/components/jira-create-adf.test.ts": 59, + "src/renderer/src/components/jira-issue-workspace-request-lifetime.test.tsx": 444, + "src/renderer/src/components/jira-project-picker-filter.test.ts": 59, + "src/renderer/src/components/jira-self-user.test.ts": 58, + "src/renderer/src/components/landing-preflight-dismissal.test.ts": 66, + "src/renderer/src/components/landing-preflight-issues.test.ts": 107, + "src/renderer/src/components/landing-preflight-runtime-boundary.test.ts": 1928, + "src/renderer/src/components/landing-preflight-unverifiable-host.test.tsx": 1541, + "src/renderer/src/components/linear-api-key-dialog-state.test.ts": 49, + "src/renderer/src/components/linear-issue-attribute-filter-coverage-agreement.test.ts": 633, + "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx": 1593, + "src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts": 63, + "src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts": 83, + "src/renderer/src/components/linear-issue-project-selector.test.tsx": 453, "src/renderer/src/components/linear-issue-text-draft-state.test.ts": 54, - "src/renderer/src/components/linear-issue-text-save-plan.test.ts": 32, - "src/renderer/src/components/linear-issue-view-storage.test.ts": 528, - "src/renderer/src/components/linear-issue-workspace-api-parity.test.ts": 27, - "src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx": 393, - "src/renderer/src/components/linear-issue-workspace-header.test.ts": 50, - "src/renderer/src/components/linear-issue-workspace-text.test.ts": 127, - "src/renderer/src/components/linear-project-presentation.test.ts": 44, - "src/renderer/src/components/linear-project-search-query.test.ts": 50, - "src/renderer/src/components/linear-project-view-surfaces.test.tsx": 1038, - "src/renderer/src/components/linear-scope-selector.test.ts": 450, - "src/renderer/src/components/link-actions/LinkActionPopover.test.tsx": 804, - "src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts": 116, - "src/renderer/src/components/mobile/MobileHero.test.tsx": 2957, - "src/renderer/src/components/mobile/MobilePage.test.tsx": 1990, - "src/renderer/src/components/mobile/MobilePageToolbar.test.tsx": 606, - "src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx": 1041, - "src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 827, - "src/renderer/src/components/mobile/mobile-page-stage.test.ts": 45, - "src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 481, - "src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 361, - "src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx": 623, - "src/renderer/src/components/native-chat-restart-action-notifications.test.ts": 113, - "src/renderer/src/components/native-chat-resume-activity-label.test.ts": 108, - "src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts": 1229, - "src/renderer/src/components/native-chat-resume-failure-guidance.test.ts": 84, - "src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts": 56, - "src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx": 1564, - "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": 872, - "src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.test.tsx": 990, - "src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx": 1274, - "src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx": 874, - "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx": 1457, - "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx": 565, - "src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx": 750, - "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx": 688, - "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx": 986, - "src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx": 3718, - "src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx": 4072, - "src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx": 4416, - "src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx": 2482, - "src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx": 11804, - "src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx": 3611, - "src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx": 3017, - "src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx": 3343, - "src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx": 4901, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx": 2633, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx": 2349, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx": 3391, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx": 1597, - "src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx": 5207, - "src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx": 1294, - "src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx": 2046, - "src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx": 752, - "src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 2656, - "src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx": 1266, - "src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx": 947, - "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx": 1217, - "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 950, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx": 2046, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx": 2769, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx": 1475, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx": 16705, - "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx": 17563, - "src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx": 979, - "src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx": 1078, - "src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 1027, - "src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx": 1215, - "src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx": 868, - "src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx": 898, - "src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": 967, - "src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx": 1191, - "src/renderer/src/components/native-chat/NativeChatView.test.tsx": 586, - "src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx": 1682, - "src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx": 419, - "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx": 5346, - "src/renderer/src/components/native-chat/background-task-roster.test.ts": 98, - "src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts": 1924, - "src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts": 124, - "src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts": 45, - "src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts": 95, - "src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts": 1516, - "src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts": 33, - "src/renderer/src/components/native-chat/native-chat-availability.test.ts": 39, - "src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx": 2042, - "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx": 2490, - "src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts": 38, - "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 1320, - "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 622, - "src/shared/native-chat-scope-cache.test.ts": 47, - "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 98, - "src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx": 1742, - "src/renderer/src/components/native-chat/native-chat-context-usage-summary.test.ts": 54, - "src/renderer/src/components/native-chat/native-chat-diff.test.ts": 53, - "src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts": 56, - "src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts": 53, - "src/renderer/src/components/native-chat/native-chat-file-link.test.ts": 161, - "src/renderer/src/components/native-chat/native-chat-font-scale.test.ts": 60, - "src/renderer/src/components/native-chat/native-chat-http-link-source-owner.test.ts": 69, - "src/renderer/src/components/native-chat/native-chat-image-paste.test.ts": 40, - "src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts": 1250, - "src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 96, - "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 148, - "src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 117, - "src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts": 52, - "src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts": 75, - "src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts": 74, - "src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts": 1739, - "src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 44, - "src/renderer/src/components/native-chat/native-chat-live-status.test.ts": 83, - "src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 73, - "src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts": 83, - "src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts": 102, - "src/renderer/src/components/native-chat/native-chat-noise.test.ts": 32, - "src/renderer/src/components/native-chat/native-chat-pagination.test.ts": 62, - "src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx": 558, - "src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts": 48, - "src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts": 66, - "src/renderer/src/components/native-chat/native-chat-pending.test.ts": 86, - "src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts": 34, - "src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts": 189, - "src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts": 157, - "src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts": 375, - "src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts": 110, - "src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts": 57, - "src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts": 455, - "src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 39, - "src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 231, - "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1274, - "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 306, - "src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts": 69, - "src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts": 50, - "src/renderer/src/components/native-chat/native-chat-send.test.ts": 52, - "src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts": 107, - "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts": 1585, - "src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts": 55, - "src/renderer/src/components/native-chat/native-chat-session-transport.test.ts": 88, - "src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts": 411, - "src/renderer/src/components/native-chat/native-chat-shortcut.test.ts": 44, + "src/renderer/src/components/linear-issue-text-save-plan.test.ts": 52, + "src/renderer/src/components/linear-issue-view-storage.test.ts": 174, + "src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx": 222, + "src/renderer/src/components/linear-issue-workspace-text.test.ts": 109, + "src/renderer/src/components/linear-project-presentation.test.ts": 65, + "src/renderer/src/components/linear-project-search-query.test.ts": 49, + "src/renderer/src/components/linear-project-view-surfaces.test.tsx": 504, + "src/renderer/src/components/linear-scope-selector.test.ts": 211, + "src/renderer/src/components/link-actions/LinkActionPopover.test.tsx": 545, + "src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts": 121, + "src/renderer/src/components/mobile/MobileHero.test.tsx": 2284, + "src/renderer/src/components/mobile/MobilePage.test.tsx": 1610, + "src/renderer/src/components/mobile/MobilePageToolbar.test.tsx": 313, + "src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx": 516, + "src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 461, + "src/renderer/src/components/mobile/mobile-page-stage.test.ts": 39, + "src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 155, + "src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 298, + "src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx": 309, + "src/renderer/src/components/native-chat-launch-resume-decision.test.tsx": 1492, + "src/renderer/src/components/native-chat-restart-action-notifications.test.ts": 129, + "src/renderer/src/components/native-chat-resume-activity-label.test.ts": 107, + "src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx": 2316, + "src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts": 1826, + "src/renderer/src/components/native-chat-resume-failure-guidance.test.ts": 102, + "src/renderer/src/components/native-chat-resume-finished-run.test.tsx": 2925, + "src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts": 106, + "src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts": 1502, + "src/renderer/src/components/native-chat-resume-run.test.ts": 99, + "src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx": 2367, + "src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx": 1911, + "src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx": 1766, + "src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx": 2314, + "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": 428, + "src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.test.tsx": 456, + "src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx": 682, + "src/renderer/src/components/native-chat/NativeChatCodeBlock.load-failure.test.tsx": 482, + "src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx": 4855, + "src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx": 2164, + "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx": 1040, + "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx": 494, + "src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx": 450, + "src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx": 681, + "src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx": 399, + "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx": 1768, + "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx": 1710, + "src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx": 673, + "src/renderer/src/components/native-chat/NativeChatJumpToLatest.test.tsx": 363, + "src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx": 1267, + "src/renderer/src/components/native-chat/NativeChatMarkdown.test.tsx": 1968, + "src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx": 2370, + "src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx": 5931, + "src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx": 4308, + "src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx": 4124, + "src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx": 3124, + "src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx": 1738, + "src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx": 3019, + "src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx": 16129, + "src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx": 5316, + "src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx": 2450, + "src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx": 3416, + "src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx": 4465, + "src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx": 3209, + "src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx": 6211, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx": 2860, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx": 3458, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx": 3074, + "src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx": 2773, + "src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx": 7809, + "src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx": 2520, + "src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx": 975, + "src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx": 2763, + "src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx": 2752, + "src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx": 530, + "src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx": 922, + "src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 2999, + "src/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsx": 2066, + "src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx": 2028, + "src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx": 670, + "src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx": 565, + "src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx": 2144, + "src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx": 1044, + "src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx": 1795, + "src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx": 2689, + "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.empty-answer.test.tsx": 397, + "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx": 1020, + "src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx": 3415, + "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx": 3672, + "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx": 3511, + "src/renderer/src/components/native-chat/NativeChatResolvedView.reveal-latest.test.tsx": 2755, + "src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx": 3611, + "src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx": 3255, + "src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx": 2974, + "src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx": 589, + "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx": 1366, + "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 1137, + "src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.test.tsx": 491, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx": 2905, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.availability-notice.test.tsx": 2847, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx": 3314, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx": 2758, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx": 3114, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx": 3169, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx": 2787, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx": 2576, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx": 2687, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx": 2836, + "src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx": 459, + "src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx": 1152, + "src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 489, + "src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx": 594, + "src/renderer/src/components/native-chat/NativeChatToolFileTarget.test.tsx": 502, + "src/renderer/src/components/native-chat/NativeChatToolLine.test.tsx": 1353, + "src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx": 1214, + "src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx": 1029, + "src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": 1212, + "src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.test.tsx": 841, + "src/renderer/src/components/native-chat/NativeChatToolRunMemberList.identity.test.tsx": 786, + "src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx": 1913, + "src/renderer/src/components/native-chat/NativeChatTranscriptRow.work-run.test.tsx": 2772, + "src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx": 2341, + "src/renderer/src/components/native-chat/NativeChatUserMessageFold.test.tsx": 2829, + "src/renderer/src/components/native-chat/NativeChatView.test.tsx": 481, + "src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx": 869, + "src/renderer/src/components/native-chat/NativeChatVisualTab.test.tsx": 1828, + "src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx": 2038, + "src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx": 378, + "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx": 1686, + "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx": 1946, + "src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts": 1046, + "src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts": 684, + "src/renderer/src/components/native-chat/background-task-words-text.test.ts": 158, + "src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts": 1797, + "src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts": 84, + "src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts": 53, + "src/renderer/src/components/native-chat/native-chat-agent-message-sender-host.test.tsx": 3468, + "src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx": 1721, + "src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts": 592, + "src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts": 324, + "src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts": 346, + "src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts": 1810, + "src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts": 66, + "src/renderer/src/components/native-chat/native-chat-availability.test.ts": 62, + "src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx": 2665, + "src/renderer/src/components/native-chat/native-chat-command-language.test.ts": 58, + "src/renderer/src/components/native-chat/native-chat-compaction-visibility.test.ts": 450, + "src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx": 1883, + "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx": 2116, + "src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts": 223, + "src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts": 48, + "src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts": 118, + "src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx": 1926, + "src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts": 1553, + "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 1112, + "src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx": 1838, + "src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx": 1111, + "src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts": 40, + "src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx": 1254, + "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 256, + "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 123, + "src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx": 1909, + "src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx": 954, + "src/renderer/src/components/native-chat/native-chat-context-command.test.tsx": 501, + "src/renderer/src/components/native-chat/native-chat-context-usage-summary.test.ts": 52, + "src/renderer/src/components/native-chat/native-chat-diff.test.ts": 69, + "src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts": 50, + "src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts": 92, + "src/renderer/src/components/native-chat/native-chat-element-file-drop.test.tsx": 1865, + "src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx": 594, + "src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts": 1427, + "src/renderer/src/components/native-chat/native-chat-file-link.test.ts": 366, + "src/renderer/src/components/native-chat/native-chat-find-visibility.test.ts": 135, + "src/renderer/src/components/native-chat/native-chat-floating-file-drop.test.tsx": 773, + "src/renderer/src/components/native-chat/native-chat-font-size.test.ts": 82, + "src/renderer/src/components/native-chat/native-chat-http-link-source-owner.test.ts": 56, + "src/renderer/src/components/native-chat/native-chat-image-paste.test.ts": 69, + "src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts": 1523, + "src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 301, + "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 115, + "src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 129, + "src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts": 51, + "src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts": 63, + "src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts": 1604, + "src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 60, + "src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts": 314, + "src/renderer/src/components/native-chat/native-chat-live-status.test.ts": 79, + "src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 280, + "src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts": 298, + "src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts": 388, + "src/renderer/src/components/native-chat/native-chat-observed-send.test.ts": 82, + "src/renderer/src/components/native-chat/native-chat-owner-selectors.test.tsx": 505, + "src/renderer/src/components/native-chat/native-chat-pagination.test.ts": 43, + "src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx": 435, + "src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts": 65, + "src/renderer/src/components/native-chat/native-chat-paste-routing.test.tsx": 1916, + "src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts": 83, + "src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts": 63, + "src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts": 74, + "src/renderer/src/components/native-chat/native-chat-pending.test.ts": 133, + "src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts": 60, + "src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts": 471, + "src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts": 57, + "src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx": 2153, + "src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts": 118, + "src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts": 435, + "src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx": 2050, + "src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts": 363, + "src/renderer/src/components/native-chat/native-chat-reader-scroll-input.test.tsx": 276, + "src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts": 51, + "src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts": 346, + "src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 101, + "src/renderer/src/components/native-chat/native-chat-runtime-owner-selector.test.ts": 371, + "src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 326, + "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1348, + "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 222, + "src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts": 45, + "src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts": 38, + "src/renderer/src/components/native-chat/native-chat-send.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-sent-prompt-history.test.ts": 252, + "src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts": 321, + "src/renderer/src/components/native-chat/native-chat-session-option-discovery.test.ts": 1181, + "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts": 1681, + "src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts": 60, + "src/renderer/src/components/native-chat/native-chat-session-transport.test.ts": 123, + "src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts": 246, + "src/renderer/src/components/native-chat/native-chat-shortcut.test.ts": 60, "src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts": 68, - "src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts": 31, - "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx": 1959, - "src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts": 31, - "src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts": 57, - "src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts": 45, - "src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts": 56, - "src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts": 69, - "src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts": 58, - "src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts": 76, - "src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts": 99, - "src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts": 91, - "src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts": 66, - "src/renderer/src/components/native-chat/native-chat-view-file-drop.test.tsx": 806, - "src/renderer/src/components/native-chat/native-chat-view-state.test.ts": 57, - "src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 135, - "src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 680, + "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx": 1857, + "src/renderer/src/components/native-chat/native-chat-structured-session-notices.command-failure.test.tsx": 1836, + "src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx": 1788, + "src/renderer/src/components/native-chat/native-chat-subagent-delegation.test.ts": 61, + "src/renderer/src/components/native-chat/native-chat-subagent-group-words-text.test.ts": 159, + "src/renderer/src/components/native-chat/native-chat-subagent-handoff-copy-budget.test.ts": 339, + "src/renderer/src/components/native-chat/native-chat-subagent-section-work-runs.test.ts": 377, + "src/renderer/src/components/native-chat/native-chat-subagent-sections-retention.test.ts": 774, + "src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts": 444, + "src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx": 789, + "src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts": 47, + "src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts": 280, + "src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts": 69, + "src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts": 68, + "src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts": 73, + "src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts": 72, + "src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts": 355, + "src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts": 328, + "src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts": 328, + "src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts": 62, + "src/renderer/src/components/native-chat/native-chat-view-file-drop.test.tsx": 1879, + "src/renderer/src/components/native-chat/native-chat-view-state.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx": 1741, + "src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts": 258, + "src/renderer/src/components/native-chat/native-chat-waiting-partition-budget.test.ts": 653, + "src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 330, + "src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts": 419, + "src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 300, "src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts": 53, - "src/renderer/src/components/native-chat/omp-model-switch-capability.test.ts": 84, - "src/renderer/src/components/native-chat/serialized-screen-background-rows.test.ts": 134, - "src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts": 97, - "src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts": 77, - "src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts": 437, - "src/renderer/src/components/native-chat/structured-agent-session-rail-outline.test.ts": 36, - "src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts": 246, - "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx": 2216, - "src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts": 1416, - "src/renderer/src/components/native-chat/structured-attention-surface.test.ts": 994, - "src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx": 697, - "src/renderer/src/components/native-chat/use-native-chat-composer-app-menu-selection.test.tsx": 528, - "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx": 718, - "src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx": 631, - "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx": 580, - "src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx": 564, - "src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx": 708, - "src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 493, - "src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx": 578, - "src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx": 1460, - "src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts": 1638, - "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx": 620, - "src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx": 567, - "src/renderer/src/components/native-chat/use-native-chat-link-actions.test.tsx": 1582, - "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts": 1421, - "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts": 2357, - "src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts": 5417, - "src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts": 790, - "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": 2127, - "src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx": 1713, - "src/renderer/src/components/native-chat/use-native-chat-rail-history-jump.test.ts": 665, - "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts": 563, - "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": 346, - "src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx": 2103, - "src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts": 792, - "src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx": 862, - "src/renderer/src/components/native-chat/use-native-chat-skills.test.ts": 835, - "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": 746, - "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts": 1599, - "src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx": 556, - "src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx": 660, - "src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx": 2782, - "src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx": 935, - "src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx": 448, - "src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx": 1272, - "src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx": 1393, - "src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx": 608, - "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx": 1632, - "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx": 2498, - "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx": 2251, - "src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx": 2510, - "src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx": 2546, - "src/renderer/src/components/native-chat/use-structured-agent-session-rail-outline.test.tsx": 707, - "src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx": 1321, - "src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx": 570, - "src/renderer/src/components/native-chat/use-structured-agent-session-transport.test.tsx": 605, - "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx": 2591, - "src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx": 481, - "src/renderer/src/components/network/CustomAddressDialog.test.tsx": 584, - "src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 1181, - "src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 936, - "src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 1046, - "src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx": 407, - "src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx": 1539, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-provider-boundaries.test.ts": 1258, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts": 1446, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts": 42, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx": 1142, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx": 1292, - "src/renderer/src/components/new-workspace/base-ref-picker-visibility.test.ts": 49, - "src/renderer/src/components/new-workspace/project-combobox-matching.test.ts": 89, - "src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts": 33, - "src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts": 247, - "src/renderer/src/components/new-workspace/smart-workspace-source-popover-focus.test.ts": 503, - "src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts": 116, - "src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx": 629, - "src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx": 454, - "src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts": 966, - "src/renderer/src/components/onboarding/AgentStep.test.tsx": 408, - "src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx": 1399, - "src/renderer/src/components/onboarding/NotificationStep.test.tsx": 1357, - "src/renderer/src/components/onboarding/OnboardingFlow.test.tsx": 5689, - "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx": 579, - "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts": 2643, - "src/renderer/src/components/onboarding/ThemeStep.test.ts": 271, - "src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx": 673, - "src/renderer/src/components/onboarding/agent-picked-payload.test.ts": 55, - "src/renderer/src/components/onboarding/onboarding-dismiss-target.test.ts": 37, - "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts": 1585, - "src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts": 281, - "src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts": 65, - "src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts": 1229, - "src/renderer/src/components/onboarding/use-onboarding-flow.test.ts": 284, - "src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.test.ts": 37, - "src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx": 515, - "src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx": 430, - "src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx": 447, - "src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx": 622, - "src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx": 654, - "src/renderer/src/components/pet/PetOverlay.test.ts": 1580, - "src/renderer/src/components/pet/pet-agent-state.test.ts": 55, - "src/renderer/src/components/pet/pet-blob-cache.test.ts": 65, - "src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx": 743, - "src/renderer/src/components/pet/pet-overlay-position.test.ts": 1053, - "src/renderer/src/components/pet/pet-overlay-visibility.test.ts": 39, - "src/renderer/src/components/pet/sprite-animation-css.test.ts": 38, - "src/renderer/src/components/pet/usePetPointerInteraction.test.ts": 328, - "src/renderer/src/components/pet/usePetUrl.test.tsx": 651, - "src/renderer/src/components/ports/WorkspacePortScanner.test.tsx": 3020, - "src/renderer/src/components/pr-check-counts.test.ts": 107, - "src/renderer/src/components/pr-checks-fix-prompt.test.ts": 56, - "src/renderer/src/components/pr-comments-resolution-prompt.test.ts": 40, - "src/renderer/src/components/provider-check-classification-parity.test.ts": 112, - "src/renderer/src/components/pull-request-page-host-boundary.test.ts": 60, - "src/renderer/src/components/pull-request-page/cache/file-content.test.ts": 61, - "src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx": 898, - "src/renderer/src/components/pull-request-page/mentions/options.test.ts": 53, - "src/renderer/src/components/pull-request-page/mentions/query.test.ts": 46, - "src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts": 241, - "src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx": 2090, - "src/renderer/src/components/quick-open-file-list.react.test.tsx": 1637, - "src/renderer/src/components/quick-open-file-list.test.ts": 1005, - "src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx": 799, - "src/renderer/src/components/quick-open-install-rg-guidance.test.ts": 254, - "src/renderer/src/components/quick-open-search.test.ts": 259, - "src/renderer/src/components/repo/CreateFromPicker.test.tsx": 1041, - "src/renderer/src/components/repo/NestedRepoChecklist.test.tsx": 274, - "src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts": 414, - "src/renderer/src/components/repo/repo-icon.emoji-centering.test.tsx": 469, - "src/renderer/src/components/repo/repo-icon.test.tsx": 554, - "src/renderer/src/components/right-sidebar/ActionButton.test.tsx": 3109, - "src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx": 3107, - "src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx": 837, - "src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx": 1050, - "src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx": 2126, - "src/renderer/src/components/right-sidebar/AiVaultSessionListBar.test.tsx": 1195, - "src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx": 1968, - "src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx": 2633, - "src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.test.tsx": 851, - "src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx": 2132, - "src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx": 2283, - "src/renderer/src/components/right-sidebar/ChecksPanel.updated-at-metadata.test.tsx": 566, - "src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx": 2283, - "src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx": 2862, - "src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx": 3772, - "src/renderer/src/components/right-sidebar/CommitArea.test.tsx": 9563, - "src/renderer/src/components/right-sidebar/FileExplorerNameFilter.test.tsx": 442, - "src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx": 251, - "src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx": 1137, - "src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.test.tsx": 286, - "src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.row-handlers.test.tsx": 1865, - "src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx": 1859, - "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx": 756, - "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx": 540, - "src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 713, - "src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx": 1252, - "src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx": 916, - "src/renderer/src/components/right-sidebar/HostedReviewActions.draft.test.tsx": 922, - "src/renderer/src/components/right-sidebar/PluginPanel.test.tsx": 883, - "src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 2949, - "src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx": 1959, - "src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx": 526, - "src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx": 565, - "src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx": 1480, - "src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts": 3776, - "src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts": 48, - "src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts": 47, - "src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts": 3689, - "src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts": 24, - "src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx": 134, - "src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx": 4632, - "src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts": 56, - "src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx": 4125, - "src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts": 53, - "src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts": 2135, - "src/renderer/src/components/right-sidebar/SourceControl.resolve-conflicts-prompt.test.ts": 2081, - "src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx": 3225, - "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx": 2339, - "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx": 361, - "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts": 551, - "src/renderer/src/components/right-sidebar/active-checks-status.test.ts": 1236, - "src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx": 599, - "src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts": 49, - "src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx": 997, - "src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts": 531, - "src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts": 67, - "src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts": 49, - "src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts": 29, - "src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts": 47, - "src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts": 43, - "src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx": 731, - "src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts": 142, - "src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts": 38, - "src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts": 127, - "src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts": 58, - "src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts": 64, - "src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts": 42, - "src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts": 81, - "src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts": 52, - "src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts": 112, - "src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts": 531, - "src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts": 5506, - "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts": 575, - "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts": 66, - "src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts": 169, - "src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx": 705, - "src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts": 95, - "src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts": 45, - "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts": 49, - "src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts": 48, - "src/renderer/src/components/right-sidebar/check-details-resize.test.ts": 30, - "src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts": 58, - "src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx": 1439, - "src/renderer/src/components/right-sidebar/checks-panel-async-result-key.test.ts": 46, - "src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx": 1497, - "src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts": 113, - "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts": 59, - "src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts": 65, - "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts": 51, - "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts": 31, - "src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts": 85, - "src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts": 52, - "src/renderer/src/components/right-sidebar/checks-panel-review.test.ts": 44, - "src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts": 79, - "src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts": 40, - "src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx": 4868, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx": 2604, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx": 597, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx": 2098, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx": 471, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx": 2233, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx": 678, - "src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts": 69, - "src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts": 40, - "src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts": 43, - "src/renderer/src/components/right-sidebar/create-review-draft-title.test.ts": 38, - "src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts": 37, - "src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts": 66, - "src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts": 41, - "src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts": 147, - "src/renderer/src/components/right-sidebar/file-explorer-dir-load-tracker.test.ts": 40, - "src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts": 239, - "src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts": 87, - "src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx": 2626, - "src/renderer/src/components/right-sidebar/file-explorer-entries.test.ts": 29, - "src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts": 1574, - "src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx": 842, - "src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx": 646, - "src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts": 677, - "src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts": 66, - "src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts": 51, - "src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts": 1352, - "src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts": 58, - "src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts": 42, - "src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts": 40, - "src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts": 41, - "src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts": 28, - "src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts": 51, - "src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts": 64, - "src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts": 1568, - "src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts": 2028, - "src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts": 58, - "src/renderer/src/components/right-sidebar/file-search-include-pattern.test.ts": 54, - "src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts": 67, - "src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts": 47, - "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts": 60, - "src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts": 247, - "src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts": 40, - "src/renderer/src/components/right-sidebar/git-status-refresh.test.ts": 173, - "src/renderer/src/components/right-sidebar/github-pr-link-modal.test.ts": 42, - "src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts": 58, - "src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts": 91, - "src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts": 57, - "src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts": 294, - "src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts": 176, - "src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts": 172, - "src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts": 145, - "src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts": 256, - "src/renderer/src/components/right-sidebar/plugin-panel-watchdog-visibility.test.ts": 585, - "src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts": 189, - "src/renderer/src/components/right-sidebar/pr-comment-ack-summary.test.ts": 40, - "src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts": 516, - "src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts": 46, - "src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts": 73, - "src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts": 70, - "src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx": 3795, - "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts": 203, - "src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts": 52, - "src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx": 679, - "src/renderer/src/components/right-sidebar/right-panel-comment-focus-timers.test.ts": 39, - "src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts": 143, - "src/renderer/src/components/right-sidebar/right-sidebar-effective-tab.test.ts": 30, - "src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts": 42, - "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 1020, - "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.test.ts": 55, - "src/renderer/src/components/right-sidebar/right-sidebar-width.test.ts": 44, - "src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts": 203, - "src/renderer/src/components/right-sidebar/search-match-open.test.ts": 51, - "src/renderer/src/components/right-sidebar/search-rows.test.ts": 32, - "src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts": 154, - "src/renderer/src/components/right-sidebar/source-control-actions.test.ts": 51, - "src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts": 33, - "src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.test.ts": 143, - "src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx": 732, - "src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts": 120, - "src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx": 1334, - "src/renderer/src/components/right-sidebar/source-control-commit-eligibility.test.ts": 49, - "src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts": 99, - "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts": 72, - "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts": 38, - "src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts": 115, - "src/renderer/src/components/right-sidebar/source-control-created-review-link.test.ts": 50, - "src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts": 109, - "src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx": 321, - "src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts": 135, - "src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts": 222, - "src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts": 27, - "src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx": 179, - "src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts": 53, - "src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx": 602, - "src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts": 416, - "src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts": 99, - "src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts": 35, - "src/renderer/src/components/right-sidebar/source-control-huge-repo-warning-dismissals.test.ts": 153, - "src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts": 195, - "src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts": 56, - "src/renderer/src/components/right-sidebar/source-control-primary-action-tooltip.test.ts": 224, - "src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts": 97, - "src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts": 172, - "src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts": 37, - "src/renderer/src/components/right-sidebar/source-control-section-order.test.ts": 44, - "src/renderer/src/components/right-sidebar/source-control-split-open.test.ts": 50, - "src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts": 83, - "src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts": 62, - "src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts": 153, - "src/renderer/src/components/right-sidebar/source-control-too-many-changes-banner.test.tsx": 2910, - "src/renderer/src/components/right-sidebar/source-control-tree.test.ts": 73, - "src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx": 1290, - "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts": 238, - "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx": 513, - "src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx": 1796, - "src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx": 559, - "src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 766, - "src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 1386, - "src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx": 351, - "src/renderer/src/components/right-sidebar/source-control/review/suppressed-github-pr.test.ts": 35, - "src/renderer/src/components/right-sidebar/source-control/review/use-action-model.test.tsx": 369, - "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-state.test.tsx": 657, - "src/renderer/src/components/right-sidebar/status-display.test.ts": 38, - "src/renderer/src/components/right-sidebar/use-ai-vault-search-focus-request.test.tsx": 1291, - "src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx": 737, - "src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts": 1386, - "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx": 1514, - "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts": 1249, - "src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts": 63, - "src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx": 546, - "src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx": 487, - "src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx": 625, - "src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx": 526, - "src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts": 1472, - "src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx": 790, - "src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx": 608, - "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts": 2037, - "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts": 1565, - "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.test.ts": 58, - "src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts": 1455, - "src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts": 1591, - "src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts": 52, - "src/renderer/src/components/right-sidebar/useFileExplorerTree.refresh-projection-churn.test.tsx": 2884, - "src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx": 2223, - "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx": 2363, - "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts": 1640, - "src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx": 745, - "src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts": 2024, - "src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx": 594, - "src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts": 2104, - "src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts": 1852, - "src/renderer/src/components/right-sidebar/useHostedReviewStackParent.test.tsx": 650, - "src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts": 64, - "src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx": 558, - "src/renderer/src/components/settings/AccountsPane.test.tsx": 2350, - "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx": 2044, - "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts": 1623, - "src/renderer/src/components/settings/AdvancedPane.test.tsx": 427, - "src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx": 2107, - "src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx": 2692, - "src/renderer/src/components/settings/AgentsPane.test.tsx": 2430, - "src/renderer/src/components/settings/AppearancePane.test.tsx": 2232, - "src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx": 1005, - "src/renderer/src/components/settings/AutomationsSettingsPane.test.tsx": 903, - "src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx": 155, - "src/renderer/src/components/settings/BrowserClientHostedRemoteSetting.test.tsx": 1136, - "src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx": 710, - "src/renderer/src/components/settings/BrowserPane.test.ts": 54, - "src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.test.tsx": 801, - "src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx": 178, - "src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx": 1325, - "src/renderer/src/components/settings/CliSection.install-failure.test.tsx": 2294, - "src/renderer/src/components/settings/CliSection.test.tsx": 2339, - "src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx": 977, - "src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx": 1314, - "src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx": 764, - "src/renderer/src/components/settings/CommitMessageAiPane.test.tsx": 1754, - "src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx": 658, - "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx": 1844, - "src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx": 1090, - "src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx": 1118, - "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx": 1144, - "src/renderer/src/components/settings/EphemeralVmsPane.test.tsx": 4524, - "src/renderer/src/components/settings/ExperimentalPane.test.tsx": 2064, - "src/renderer/src/components/settings/FloatingWorkspacePane.test.tsx": 1671, - "src/renderer/src/components/settings/GeneralPane.test.ts": 3742, - "src/renderer/src/components/settings/GeneralRemoteServerUpdates.test.tsx": 832, - "src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx": 1415, - "src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx": 1460, - "src/renderer/src/components/settings/GhosttyImportModal.test.ts": 268, - "src/renderer/src/components/settings/GitPane.test.ts": 1095, - "src/renderer/src/components/settings/GitPane.test.tsx": 1415, - "src/renderer/src/components/settings/GrokAccountsSection.test.tsx": 954, - "src/renderer/src/components/settings/HiddenExperimentalGroup.test.tsx": 513, - "src/renderer/src/components/settings/KagiSessionLinkForm.test.ts": 1135, - "src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx": 374, - "src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx": 163, - "src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx": 1432, - "src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx": 585, - "src/renderer/src/components/settings/MachineNameField.test.tsx": 561, - "src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx": 1349, - "src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx": 1228, - "src/renderer/src/components/settings/MobilePairingQrSection.test.tsx": 574, - "src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx": 1897, - "src/renderer/src/components/settings/MobilePane.test.tsx": 1448, - "src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx": 1173, - "src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx": 2849, - "src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx": 965, - "src/renderer/src/components/settings/NotificationsPane.test.tsx": 1611, - "src/renderer/src/components/settings/OpenInMenuSetting.test.ts": 483, - "src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx": 660, - "src/renderer/src/components/settings/OrchestrationPane.test.tsx": 2500, - "src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx": 142, - "src/renderer/src/components/settings/PluginConsentDialog.test.tsx": 1603, - "src/renderer/src/components/settings/PluginInstallDialog.test.tsx": 1086, - "src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts": 880, - "src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx": 857, - "src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 587, - "src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 1082, - "src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 1906, - "src/renderer/src/components/settings/PrivacyPane.test.ts": 1541, - "src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 1249, - "src/renderer/src/components/settings/QuickCommandsList.test.tsx": 1098, - "src/renderer/src/components/settings/QuickCommandsPane.test.ts": 1711, - "src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx": 935, - "src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx": 866, - "src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx": 989, - "src/renderer/src/components/settings/RepositoryHooksSection.test.ts": 838, - "src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx": 2695, - "src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx": 2705, - "src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx": 555, - "src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx": 1099, - "src/renderer/src/components/settings/RepositoryPane.test.ts": 3138, - "src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx": 450, - "src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts": 593, - "src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx": 532, - "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 1724, - "src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 589, - "src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx": 434, - "src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx": 935, - "src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx": 1157, - "src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx": 2196, - "src/renderer/src/components/settings/Settings.load-performance.test.ts": 36, - "src/renderer/src/components/settings/SettingsConstants.test.ts": 179, - "src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 1401, - "src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx": 816, - "src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 904, - "src/renderer/src/components/settings/SettingsSidebar.test.tsx": 2031, - "src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 791, - "src/renderer/src/components/settings/ShortcutCommandBlock.test.tsx": 1722, - "src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 463, - "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 1006, - "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 577, - "src/renderer/src/components/settings/SshTargetCard.test.tsx": 776, - "src/renderer/src/components/settings/SshTargetForm.test.tsx": 1083, - "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 1195, - "src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx": 592, - "src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx": 359, - "src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx": 960, - "src/renderer/src/components/settings/TasksPane.test.tsx": 1403, - "src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx": 1646, - "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 398, - "src/renderer/src/components/settings/TerminalContrastSetting.test.tsx": 1310, - "src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx": 1537, - "src/renderer/src/components/settings/TerminalPane.pwsh.test.ts": 765, - "src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx": 664, - "src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx": 885, - "src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts": 1380, - "src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx": 1149, - "src/renderer/src/components/settings/VoicePane.test.tsx": 1609, - "src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx": 672, - "src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx": 2196, - "src/renderer/src/components/settings/accounts-search.test.ts": 45, - "src/renderer/src/components/settings/agent-availability-settings.test.ts": 209, - "src/renderer/src/components/settings/agent-default-env-draft.test.ts": 41, - "src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts": 122, - "src/renderer/src/components/settings/appearance-interface-summary.test.ts": 170, - "src/renderer/src/components/settings/appearance-search.test.ts": 835, - "src/renderer/src/components/settings/appearance-status-bar-search.test.ts": 58, - "src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts": 97, - "src/renderer/src/components/settings/bitbucket-integration-card.test.tsx": 882, - "src/renderer/src/components/settings/browser-cookie-import-label.test.ts": 115, - "src/renderer/src/components/settings/browser-link-routing-localization.test.ts": 422, - "src/renderer/src/components/settings/browser-search.test.ts": 394, - "src/renderer/src/components/settings/browser-session-host-selection.test.ts": 51, - "src/renderer/src/components/settings/cli-install-failure.test.ts": 41, - "src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 739, - "src/renderer/src/components/settings/codex-account-auth-warning.test.ts": 44, - "src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 29, - "src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 389, - "src/renderer/src/components/settings/developer-permissions-search.test.ts": 410, - "src/renderer/src/components/settings/general-editor-search.test.ts": 109, - "src/renderer/src/components/settings/general-preview-tab-search.test.ts": 113, - "src/renderer/src/components/settings/host-scoped-setting-scope.test.ts": 40, - "src/renderer/src/components/settings/integrations-pane-status.test.ts": 56, - "src/renderer/src/components/settings/jira-integration-card.test.tsx": 546, - "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx": 1244, - "src/renderer/src/components/settings/mobile-network-interface-selection.test.ts": 87, - "src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts": 48, - "src/renderer/src/components/settings/mobile-pane-search.test.ts": 87, - "src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts": 86, - "src/renderer/src/components/settings/osc52-clipboard-copy.test.ts": 365, - "src/renderer/src/components/settings/plugin-install-source.test.ts": 167, - "src/renderer/src/components/settings/provider-account-scope.test.ts": 110, - "src/renderer/src/components/settings/provider-account-visibility.test.ts": 53, - "src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx": 486, - "src/renderer/src/components/settings/repository-hook-settings-draft.test.ts": 201, - "src/renderer/src/components/settings/repository-host-setup-options.test.ts": 71, - "src/renderer/src/components/settings/repository-icon-github.test.ts": 56, - "src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts": 689, - "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts": 70, - "src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx": 2289, - "src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx": 936, - "src/renderer/src/components/settings/session-history-status-copy.test.ts": 150, - "src/renderer/src/components/settings/session-search-computer-rollup.test.ts": 49, - "src/renderer/src/components/settings/setting-ownership.test.ts": 66, - "src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts": 479, - "src/renderer/src/components/settings/settings-form-option-filter.test.ts": 60, - "src/renderer/src/components/settings/settings-project-list.test.ts": 75, - "src/renderer/src/components/settings/settings-search-keywords.test.ts": 195, - "src/renderer/src/components/settings/settings-search.test.ts": 54, - "src/renderer/src/components/settings/settings-setup-guide-progress-hook.test.tsx": 90, - "src/renderer/src/components/settings/settings-setup-guide-progress.test.ts": 1590, - "src/renderer/src/components/settings/shortcut-binding-list-mutations.test.ts": 44, - "src/renderer/src/components/settings/shortcut-definition-catalog.test.ts": 537, - "src/renderer/src/components/settings/shortcut-groups.test.ts": 116, - "src/renderer/src/components/settings/shortcut-recording-state.test.ts": 39, - "src/renderer/src/components/settings/shortcut-row-visibility.test.ts": 485, - "src/renderer/src/components/settings/sparse-preset-date.test.ts": 64, - "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 46, - "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 35, - "src/renderer/src/components/settings/ssh-target-draft.test.ts": 67, - "src/renderer/src/components/settings/ssh-target-remove.test.ts": 110, - "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 118, - "src/renderer/src/components/settings/task-source-setup-state.test.ts": 79, - "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 892, - "src/renderer/src/components/settings/terminal-preview-content.test.ts": 56, - "src/renderer/src/components/settings/terminal-search.test.ts": 193, - "src/renderer/src/components/settings/use-debounced-settings-text-draft.test.ts": 578, - "src/renderer/src/components/settings/use-mac-captured-digit-chords.test.ts": 885, - "src/renderer/src/components/settings/use-published-machine-name.test.tsx": 593, - "src/renderer/src/components/settings/use-repository-hook-settings-draft.test.tsx": 412, - "src/renderer/src/components/settings/use-session-search-status.test.tsx": 462, - "src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx": 464, - "src/renderer/src/components/settings/useGhosttyImport.test.ts": 81, - "src/renderer/src/components/settings/useWarpThemeImport.test.ts": 96, - "src/renderer/src/components/settings/worktree-symlink-path-filter.scan-budget.test.ts": 125, - "src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts": 45, - "src/renderer/src/components/setup-guide/SetupGuideModal.mount-gating.test.tsx": 1855, - "src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts": 1157, - "src/renderer/src/components/setup-guide/use-setup-guide-telemetry.test.ts": 40, - "src/renderer/src/components/shared/MacFolderAccessFixDialog.test.tsx": 1694, - "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts": 1721, - "src/renderer/src/components/shared/useDaemonActions.test.tsx": 665, - "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx": 345, - "src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx": 1413, - "src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx": 411, - "src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 555, - "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 46, - "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 748, - "src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 568, - "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 891, - "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 1413, - "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 158, - "src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx": 2111, - "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 534, - "src/renderer/src/components/sidebar/CacheTimer.test.tsx": 303, - "src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx": 1751, - "src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx": 2429, - "src/renderer/src/components/sidebar/CommentMarkdown.test.tsx": 2215, - "src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts": 49, - "src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx": 1072, - "src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx": 1045, - "src/renderer/src/components/sidebar/FilterToggleRow.test.tsx": 792, - "src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx": 1329, - "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx": 1307, - "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx": 7136, - "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx": 1428, - "src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.test.tsx": 2144, - "src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 1145, - "src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx": 2102, - "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx": 627, - "src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx": 1725, - "src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx": 89, - "src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx": 59, - "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx": 946, - "src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx": 455, - "src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx": 1115, - "src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx": 90, - "src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 589, - "src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts": 43, - "src/renderer/src/components/sidebar/SetupScriptPromptCardShell.test.tsx": 538, - "src/renderer/src/components/sidebar/Sidebar.test.tsx": 747, - "src/renderer/src/components/sidebar/SidebarAgentsList.test.tsx": 2134, - "src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx": 3073, - "src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx": 522, - "src/renderer/src/components/sidebar/SidebarHeader.test.tsx": 1124, - "src/renderer/src/components/sidebar/SidebarNav.test.tsx": 1553, - "src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.test.tsx": 1398, - "src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx": 1068, - "src/renderer/src/components/sidebar/SidebarToolbar.test.tsx": 1152, - "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx": 765, - "src/renderer/src/components/sidebar/StatusIndicator.test.ts": 312, - "src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx": 1203, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx": 2186, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx": 2422, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx": 2043, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx": 1839, - "src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx": 1498, - "src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx": 686, - "src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx": 796, - "src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx": 1013, - "src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx": 969, - "src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx": 1815, - "src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx": 3499, - "src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx": 1988, - "src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1492, - "src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx": 1128, - "src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 976, - "src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 722, - "src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1607, - "src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1815, - "src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx": 1253, - "src/renderer/src/components/sidebar/WorktreeCard.test.ts": 2003, - "src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx": 1121, - "src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx": 715, - "src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx": 951, - "src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx": 625, - "src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx": 608, - "src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.test.tsx": 765, - "src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx": 3427, - "src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx": 2336, - "src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx": 997, - "src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx": 2296, - "src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx": 306, - "src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx": 1296, - "src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx": 1521, - "src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx": 2442, - "src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 1831, - "src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx": 156, - "src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx": 837, - "src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 1550, - "src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts": 1867, - "src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 1777, - "src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 2092, - "src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 5067, - "src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 1705, - "src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 3618, - "src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx": 1918, - "src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx": 2111, - "src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx": 392, - "src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts": 1434, - "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx": 160, - "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 537, - "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx": 476, - "src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx": 1597, - "src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx": 917, - "src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx": 957, - "src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts": 109, - "src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts": 163, - "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts": 56, - "src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts": 60, - "src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts": 33, - "src/renderer/src/components/sidebar/clone-defaults.test.ts": 72, - "src/renderer/src/components/sidebar/create-project-defaults.test.ts": 33, - "src/renderer/src/components/sidebar/default-branch-folder-project-root.test.ts": 924, - "src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts": 1322, - "src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts": 46, - "src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx": 634, - "src/renderer/src/components/sidebar/delete-worktree-flow.test.ts": 799, - "src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts": 320, - "src/renderer/src/components/sidebar/delete-worktree-toast.test.ts": 89, - "src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts": 65, - "src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts": 1580, - "src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts": 159, - "src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx": 2105, - "src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts": 89, - "src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx": 1905, - "src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts": 3745, - "src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts": 1959, - "src/renderer/src/components/sidebar/host-header-drag.test.tsx": 386, - "src/renderer/src/components/sidebar/host-header-menu-items.test.ts": 49, - "src/renderer/src/components/sidebar/host-rename-remove.test.ts": 49, - "src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts": 51, - "src/renderer/src/components/sidebar/host-section-order.test.ts": 33, - "src/renderer/src/components/sidebar/host-section-rows.test.ts": 223, - "src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts": 1960, - "src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts": 54, - "src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts": 60, - "src/renderer/src/components/sidebar/linear-agent-skill-runtime.shell-override.test.ts": 66, - "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 51, - "src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx": 727, - "src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 447, - "src/renderer/src/components/sidebar/natural-worktree-ids.test.ts": 266, - "src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts": 80, - "src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts": 42, - "src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts": 535, - "src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx": 548, - "src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx": 937, - "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts": 134, - "src/renderer/src/components/sidebar/project-filter-reveal.test.ts": 49, - "src/renderer/src/components/sidebar/project-group-header-dom.test.ts": 33, - "src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts": 450, - "src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts": 375, - "src/renderer/src/components/sidebar/project-group-header-drag.test.ts": 565, - "src/renderer/src/components/sidebar/project-group-header-drop.test.ts": 360, - "src/renderer/src/components/sidebar/project-header-action-selector-lockstep.test.ts": 394, - "src/renderer/src/components/sidebar/project-header-color.test.ts": 200, - "src/renderer/src/components/sidebar/project-header-drag-commit.test.ts": 356, - "src/renderer/src/components/sidebar/project-header-drag-start.test.ts": 478, - "src/renderer/src/components/sidebar/project-header-drag.test.ts": 465, - "src/renderer/src/components/sidebar/project-header-drop.test.ts": 226, + "src/renderer/src/components/native-chat/omp-model-switch-capability.test.ts": 130, + "src/renderer/src/components/native-chat/serialized-screen-background-rows.test.ts": 124, + "src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts": 306, + "src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts": 435, + "src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts": 369, + "src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts": 392, + "src/renderer/src/components/native-chat/structured-agent-session-legacy-outbox.test.ts": 360, + "src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.test.ts": 218, + "src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts": 405, + "src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts": 325, + "src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts": 419, + "src/renderer/src/components/native-chat/structured-agent-session-queue-request.test.ts": 52, + "src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts": 219, + "src/renderer/src/components/native-chat/structured-agent-session-rail-outline.test.ts": 49, + "src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts": 294, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx": 1549, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx": 1883, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx": 1608, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx": 1818, + "src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts": 364, + "src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts": 1277, + "src/renderer/src/components/native-chat/structured-attention-surface.test.ts": 1681, + "src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx": 1858, + "src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts": 785, + "src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx": 588, + "src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx": 1720, + "src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx": 1718, + "src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx": 564, + "src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx": 602, + "src/renderer/src/components/native-chat/use-native-chat-composer-app-menu-selection.test.tsx": 297, + "src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.test.tsx": 189, + "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx": 1797, + "src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx": 523, + "src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx": 1613, + "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx": 457, + "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx": 614, + "src/renderer/src/components/native-chat/use-native-chat-composer-notice.test.tsx": 309, + "src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx": 279, + "src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx": 446, + "src/renderer/src/components/native-chat/use-native-chat-composer-preview-lifetime.test.tsx": 1616, + "src/renderer/src/components/native-chat/use-native-chat-composer-recall.test.tsx": 466, + "src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx": 332, + "src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 2301, + "src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx": 702, + "src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.test.tsx": 259, + "src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx": 1131, + "src/renderer/src/components/native-chat/use-native-chat-file-link-existence.test.tsx": 706, + "src/renderer/src/components/native-chat/use-native-chat-find.test.tsx": 1343, + "src/renderer/src/components/native-chat/use-native-chat-font-size.test.tsx": 660, + "src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts": 1604, + "src/renderer/src/components/native-chat/use-native-chat-host-outage.test.tsx": 1609, + "src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts": 1685, + "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx": 388, + "src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx": 368, + "src/renderer/src/components/native-chat/use-native-chat-link-actions.test.tsx": 844, + "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts": 1561, + "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts": 1650, + "src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts": 1567, + "src/renderer/src/components/native-chat/use-native-chat-mention-files.test.tsx": 1739, + "src/renderer/src/components/native-chat/use-native-chat-menu-paste-lifetime.test.tsx": 1722, + "src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts": 778, + "src/renderer/src/components/native-chat/use-native-chat-paced-text.remount.test.tsx": 288, + "src/renderer/src/components/native-chat/use-native-chat-paced-text.test.ts": 45, + "src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.test.tsx": 317, + "src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx": 350, + "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": 1548, + "src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx": 295, + "src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts": 1743, + "src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx": 1968, + "src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx": 431, + "src/renderer/src/components/native-chat/use-native-chat-rail-history-jump.test.ts": 586, + "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts": 217, + "src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx": 725, + "src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx": 1793, + "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": 252, + "src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx": 1640, + "src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts": 805, + "src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx": 972, + "src/renderer/src/components/native-chat/use-native-chat-skills.test.ts": 1523, + "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": 983, + "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts": 590, + "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts": 1442, + "src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.first-reply.test.tsx": 261, + "src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx": 355, + "src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx": 684, + "src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx": 1939, + "src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx": 2211, + "src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx": 2614, + "src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx": 575, + "src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx": 533, + "src/renderer/src/components/native-chat/use-structured-agent-session-legacy-recovery.test.tsx": 2508, + "src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx": 547, + "src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx": 876, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx": 2125, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx": 1942, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx": 1799, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx": 2745, + "src/renderer/src/components/native-chat/use-structured-agent-session-paired-runtime.test.tsx": 2294, + "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx": 2071, + "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx": 2502, + "src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx": 2577, + "src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx": 1996, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx": 2429, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx": 1709, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx": 1659, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx": 1691, + "src/renderer/src/components/native-chat/use-structured-agent-session-rail-outline.test.tsx": 564, + "src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx": 2334, + "src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx": 553, + "src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts": 523, + "src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx": 247, + "src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx": 2147, + "src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx": 312, + "src/renderer/src/components/native-chat/use-structured-agent-session-transport.test.tsx": 1868, + "src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx": 2124, + "src/renderer/src/components/native-chat/use-structured-agent-session.clear-send.test.tsx": 1896, + "src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx": 2024, + "src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx": 2061, + "src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx": 2287, + "src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx": 2042, + "src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx": 2472, + "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx": 3082, + "src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx": 471, + "src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx": 540, + "src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts": 295, + "src/renderer/src/components/network/CustomAddressDialog.test.tsx": 516, + "src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 596, + "src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx": 773, + "src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 926, + "src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 563, + "src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx": 324, + "src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx": 1306, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-provider-boundaries.test.ts": 54, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts": 97, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx": 635, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx": 1078, + "src/renderer/src/components/new-workspace/base-ref-picker-visibility.test.ts": 53, + "src/renderer/src/components/new-workspace/project-combobox-matching.test.ts": 76, + "src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts": 53, + "src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts": 185, + "src/renderer/src/components/new-workspace/smart-workspace-source-popover-focus.test.ts": 179, + "src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts": 125, + "src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx": 323, + "src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx": 293, + "src/renderer/src/components/new-workspace/use-linear-source-connection.test.tsx": 878, + "src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts": 1462, + "src/renderer/src/components/new-workspace/use-smart-workspace-field-focus-controls.test.tsx": 272, + "src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx": 663, + "src/renderer/src/components/new-workspace/work-item-source-selection.test.ts": 1480, + "src/renderer/src/components/onboarding/AgentStep.test.tsx": 289, + "src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx": 1700, + "src/renderer/src/components/onboarding/NotificationStep.test.tsx": 1612, + "src/renderer/src/components/onboarding/OnboardingFlow.test.tsx": 14464, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx": 369, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts": 9982, + "src/renderer/src/components/onboarding/ThemeStep.test.ts": 132, + "src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx": 558, + "src/renderer/src/components/onboarding/agent-picked-payload.test.ts": 68, + "src/renderer/src/components/onboarding/onboarding-dismiss-target.test.ts": 51, + "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts": 1391, + "src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts": 332, + "src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts": 60, + "src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts": 1673, + "src/renderer/src/components/onboarding/use-onboarding-flow.test.ts": 159, + "src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.test.ts": 42, + "src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx": 250, + "src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx": 269, + "src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx": 372, + "src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx": 245, + "src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx": 348, + "src/renderer/src/components/pet/PetOverlay.test.ts": 1488, + "src/renderer/src/components/pet/pet-agent-state.test.ts": 95, + "src/renderer/src/components/pet/pet-blob-cache.test.ts": 66, + "src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx": 283, + "src/renderer/src/components/pet/pet-overlay-position.test.ts": 1490, + "src/renderer/src/components/pet/pet-overlay-visibility.test.ts": 49, + "src/renderer/src/components/pet/sprite-animation-css.test.ts": 57, + "src/renderer/src/components/pet/usePetPointerInteraction.test.ts": 319, + "src/renderer/src/components/pet/usePetUrl.test.tsx": 355, + "src/renderer/src/components/ports/WorkspacePortScanner.test.tsx": 2253, + "src/renderer/src/components/pr-check-counts.test.ts": 84, + "src/renderer/src/components/pr-checks-fix-prompt.test.ts": 61, + "src/renderer/src/components/pr-comments-resolution-prompt.test.ts": 48, + "src/renderer/src/components/provider-check-classification-parity.test.ts": 129, + "src/renderer/src/components/pull-request-page/cache/file-content.test.ts": 129, + "src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx": 495, + "src/renderer/src/components/pull-request-page/mentions/options.test.ts": 59, + "src/renderer/src/components/pull-request-page/mentions/query.test.ts": 54, + "src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts": 155, + "src/renderer/src/components/quick-open-file-history.test.ts": 1540, + "src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx": 1753, + "src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx": 1792, + "src/renderer/src/components/quick-open-file-list.react.test.tsx": 1934, + "src/renderer/src/components/quick-open-file-list.test.ts": 1601, + "src/renderer/src/components/quick-open-file-navigation.test.ts": 335, + "src/renderer/src/components/quick-open-history-ranking.test.ts": 59, + "src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx": 314, + "src/renderer/src/components/quick-open-install-rg-guidance.test.ts": 133, + "src/renderer/src/components/quick-open-recent-lifetime.react.test.tsx": 1679, + "src/renderer/src/components/quick-open-recent-validation.test.ts": 109, + "src/renderer/src/components/quick-open-search.test.ts": 275, + "src/renderer/src/components/quick-open-selection-owner.react.test.tsx": 1849, + "src/renderer/src/components/repo/CreateFromPicker.test.tsx": 677, + "src/renderer/src/components/repo/NestedRepoChecklist.test.tsx": 186, + "src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts": 177, + "src/renderer/src/components/repo/repo-icon.emoji-centering.test.tsx": 333, + "src/renderer/src/components/repo/repo-icon.test.tsx": 399, + "src/renderer/src/components/right-sidebar/ActionButton.test.tsx": 143, + "src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx": 3070, + "src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx": 385, + "src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx": 541, + "src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx": 2086, + "src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.test.tsx": 482, + "src/renderer/src/components/right-sidebar/AiVaultSessionListBar.test.tsx": 696, + "src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx": 2145, + "src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx": 2248, + "src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx": 2264, + "src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.test.tsx": 376, + "src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx": 2999, + "src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx": 5337, + "src/renderer/src/components/right-sidebar/ChecksPanel.updated-at-metadata.test.tsx": 249, + "src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx": 2229, + "src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx": 1890, + "src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx": 3363, + "src/renderer/src/components/right-sidebar/CommitArea.test.tsx": 6748, + "src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.errors.test.tsx": 1674, + "src/renderer/src/components/right-sidebar/FileExplorerNameFilter.test.tsx": 156, + "src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx": 432, + "src/renderer/src/components/right-sidebar/FileExplorerRow.load-error.test.tsx": 1657, + "src/renderer/src/components/right-sidebar/FileExplorerScopeNotice.test.tsx": 538, + "src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx": 1512, + "src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.test.tsx": 98, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.row-handlers.test.tsx": 1604, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx": 1525, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx": 566, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx": 428, + "src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 334, + "src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx": 1611, + "src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx": 359, + "src/renderer/src/components/right-sidebar/HostedReviewActions.draft.test.tsx": 1176, + "src/renderer/src/components/right-sidebar/PluginPanel.test.tsx": 559, + "src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 2438, + "src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx": 902, + "src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx": 197, + "src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx": 280, + "src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx": 2329, + "src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts": 133, + "src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts": 70, + "src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts": 63, + "src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts": 182, + "src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx": 146, + "src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx": 3578, + "src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts": 70, + "src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx": 3147, + "src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts": 45, + "src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts": 46, + "src/renderer/src/components/right-sidebar/SourceControl.resolve-conflicts-prompt.test.ts": 98, + "src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx": 3961, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx": 2686, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx": 258, + "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts": 376, + "src/renderer/src/components/right-sidebar/active-checks-status.test.ts": 1468, + "src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx": 328, + "src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts": 53, + "src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx": 419, + "src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts": 201, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts": 79, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts": 66, + "src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.test.tsx": 2271, + "src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts": 69, + "src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts": 72, + "src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts": 52, + "src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx": 448, + "src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts": 268, + "src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts": 77, + "src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts": 69, + "src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts": 306, + "src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts": 59, + "src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts": 103, + "src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts": 57, + "src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts": 1623, + "src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts": 337, + "src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts": 330, + "src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts": 101, + "src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts": 156, + "src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts": 1511, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts": 1041, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts": 519, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts": 84, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts": 1709, + "src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.test.ts": 1609, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx": 1626, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts": 109, + "src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts": 70, + "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts": 68, + "src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts": 42, + "src/renderer/src/components/right-sidebar/check-details-resize.test.ts": 48, + "src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts": 72, + "src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx": 701, + "src/renderer/src/components/right-sidebar/checks-panel-async-result-key.test.ts": 55, + "src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx": 1908, + "src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts": 138, + "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts": 66, + "src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts": 56, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts": 61, + "src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts": 72, + "src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts": 74, + "src/renderer/src/components/right-sidebar/checks-panel-review.test.ts": 58, + "src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts": 72, + "src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.test.ts": 49, + "src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts": 103, + "src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx": 6495, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx": 2148, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx": 1647, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.test.tsx": 228, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx": 1760, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx": 241, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx": 1661, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling-stability.test.tsx": 1756, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx": 1865, + "src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts": 65, + "src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts": 56, + "src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts": 66, + "src/renderer/src/components/right-sidebar/create-review-draft-title.test.ts": 60, + "src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts": 59, + "src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts": 76, + "src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts": 56, + "src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts": 136, + "src/renderer/src/components/right-sidebar/file-explorer-copy-file-owner.test.ts": 76, + "src/renderer/src/components/right-sidebar/file-explorer-delete-editor-owner.test.tsx": 1672, + "src/renderer/src/components/right-sidebar/file-explorer-dir-load-tracker.test.ts": 50, + "src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts": 167, + "src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts": 1416, + "src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts": 1622, + "src/renderer/src/components/right-sidebar/file-explorer-display-root.test.ts": 1628, + "src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx": 2169, + "src/renderer/src/components/right-sidebar/file-explorer-entries.test.ts": 58, + "src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts": 1581, + "src/renderer/src/components/right-sidebar/file-explorer-inline-input-ime-enter.test.tsx": 361, + "src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx": 348, + "src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx": 910, + "src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts": 389, + "src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts": 68, + "src/renderer/src/components/right-sidebar/file-explorer-linked-recovery.test.tsx": 1794, + "src/renderer/src/components/right-sidebar/file-explorer-linked-watch.test.ts": 1465, + "src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts": 70, + "src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts": 1460, + "src/renderer/src/components/right-sidebar/file-explorer-operation-owner.test.ts": 349, + "src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts": 61, + "src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts": 49, + "src/renderer/src/components/right-sidebar/file-explorer-replace-selection-order-budget.test.tsx": 338, + "src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts": 52, + "src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.test.tsx": 433, + "src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts": 53, + "src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts": 55, + "src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts": 48, + "src/renderer/src/components/right-sidebar/file-explorer-stale-retry.test.tsx": 1729, + "src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts": 1350, + "src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts": 1562, + "src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts": 1632, + "src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts": 99, + "src/renderer/src/components/right-sidebar/file-explorer-watch-selected-host.test.ts": 1457, + "src/renderer/src/components/right-sidebar/file-search-include-pattern.test.ts": 61, + "src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts": 104, + "src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts": 61, + "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts": 76, + "src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts": 318, + "src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts": 80, + "src/renderer/src/components/right-sidebar/git-status-refresh.test.ts": 430, + "src/renderer/src/components/right-sidebar/github-pr-link-modal.test.ts": 48, + "src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts": 91, + "src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts": 112, + "src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts": 55, + "src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts": 224, + "src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts": 129, + "src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts": 133, + "src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts": 111, + "src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts": 354, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog-visibility.test.ts": 404, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts": 296, + "src/renderer/src/components/right-sidebar/ports-panel-host.test.ts": 319, + "src/renderer/src/components/right-sidebar/pr-comment-ack-summary.test.ts": 49, + "src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts": 167, + "src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts": 58, + "src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts": 51, + "src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts": 113, + "src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx": 2618, + "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts": 390, + "src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts": 49, + "src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx": 297, + "src/renderer/src/components/right-sidebar/right-panel-comment-focus-timers.test.ts": 45, + "src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts": 106, + "src/renderer/src/components/right-sidebar/right-sidebar-effective-tab.test.ts": 50, + "src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts": 52, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 718, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.test.ts": 44, + "src/renderer/src/components/right-sidebar/right-sidebar-width.test.ts": 46, + "src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts": 218, + "src/renderer/src/components/right-sidebar/search-match-open.test.ts": 64, + "src/renderer/src/components/right-sidebar/search-rows.test.ts": 56, + "src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts": 270, + "src/renderer/src/components/right-sidebar/source-control-actions.test.ts": 53, + "src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts": 47, + "src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.test.ts": 134, + "src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx": 259, + "src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts": 86, + "src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx": 1703, + "src/renderer/src/components/right-sidebar/source-control-commit-eligibility.test.ts": 51, + "src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts": 110, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts": 78, + "src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts": 93, + "src/renderer/src/components/right-sidebar/source-control-created-review-link.test.ts": 49, + "src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts": 87, + "src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx": 194, + "src/renderer/src/components/right-sidebar/source-control-discard-localization.test.ts": 353, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts": 131, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts": 160, + "src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts": 48, + "src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx": 420, + "src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts": 61, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx": 372, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts": 211, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts": 71, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts": 79, + "src/renderer/src/components/right-sidebar/source-control-huge-repo-warning-dismissals.test.ts": 154, + "src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts": 434, + "src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts": 71, + "src/renderer/src/components/right-sidebar/source-control-primary-action-tooltip.test.ts": 113, + "src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts": 143, + "src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts": 148, + "src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts": 59, + "src/renderer/src/components/right-sidebar/source-control-section-order.test.ts": 67, + "src/renderer/src/components/right-sidebar/source-control-split-open.test.ts": 53, + "src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts": 56, + "src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts": 72, + "src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts": 270, + "src/renderer/src/components/right-sidebar/source-control-too-many-changes-banner.test.tsx": 421, + "src/renderer/src/components/right-sidebar/source-control-tree.test.ts": 75, + "src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx": 1485, + "src/renderer/src/components/right-sidebar/source-control/commit/rename-mutation-paths.test.ts": 55, + "src/renderer/src/components/right-sidebar/source-control/commit/source-control-discard-editor-owner.test.tsx": 315, + "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts": 226, + "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx": 323, + "src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions-capped-stage.test.tsx": 320, + "src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx": 2110, + "src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx": 417, + "src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 331, + "src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 1360, + "src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx": 300, + "src/renderer/src/components/right-sidebar/source-control/review/suppressed-github-pr.test.ts": 50, + "src/renderer/src/components/right-sidebar/source-control/review/use-action-model.test.tsx": 303, + "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx": 1779, + "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-state.test.tsx": 306, + "src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx": 1604, + "src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.test.tsx": 251, + "src/renderer/src/components/right-sidebar/source-control/sync/git-history-format.test.ts": 69, + "src/renderer/src/components/right-sidebar/source-control/sync/git-history-graph-svg.test.tsx": 98, + "src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.local-ignore.test.tsx": 164, + "src/renderer/src/components/right-sidebar/status-display.test.ts": 51, + "src/renderer/src/components/right-sidebar/use-ai-vault-search-focus-request.test.tsx": 1381, + "src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx": 453, + "src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts": 1614, + "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx": 1666, + "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts": 1698, + "src/renderer/src/components/right-sidebar/use-file-explorer-root-navigation.test.tsx": 297, + "src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts": 53, + "src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx": 297, + "src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx": 357, + "src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx": 341, + "src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx": 480, + "src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.effective-tab.test.tsx": 266, + "src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts": 1903, + "src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx": 421, + "src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx": 303, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts": 1571, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts": 1533, + "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.os-file-drop.test.tsx": 2146, + "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.test.ts": 52, + "src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts": 1433, + "src/renderer/src/components/right-sidebar/useFileExplorerImport.committed-scope.test.tsx": 291, + "src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx": 514, + "src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx": 1732, + "src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts": 1412, + "src/renderer/src/components/right-sidebar/useFileExplorerReveal.scoped-root.test.tsx": 316, + "src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts": 64, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.lifecycle.test.tsx": 1983, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.refresh-projection-churn.test.tsx": 1825, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.scoped-root.test.tsx": 1837, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx": 1690, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx": 1954, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts": 1433, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.broker.test.tsx": 286, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx": 569, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx": 346, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts": 1444, + "src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx": 1892, + "src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx": 582, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts": 1604, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts": 2655, + "src/renderer/src/components/right-sidebar/useHostedReviewStackParent.test.tsx": 296, + "src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts": 63, + "src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx": 254, + "src/renderer/src/components/settings/AccountsPane.owner-scope.test.tsx": 1292, + "src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx": 2440, + "src/renderer/src/components/settings/AccountsPane.test.tsx": 2349, + "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx": 1600, + "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts": 1496, + "src/renderer/src/components/settings/AdvancedPane.test.tsx": 496, + "src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx": 1728, + "src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx": 2128, + "src/renderer/src/components/settings/AgentsPane.test.tsx": 2229, + "src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx": 743, + "src/renderer/src/components/settings/AppearanceChatContrastControls.draft.test.tsx": 697, + "src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx": 1066, + "src/renderer/src/components/settings/AppearanceChatSection.test.tsx": 1335, + "src/renderer/src/components/settings/AppearancePane.test.tsx": 1175, + "src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx": 859, + "src/renderer/src/components/settings/AutomationsSettingsPane.test.tsx": 735, + "src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx": 116, + "src/renderer/src/components/settings/BrowserClientHostedRemoteSetting.test.tsx": 710, + "src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx": 564, + "src/renderer/src/components/settings/BrowserPane.test.ts": 49, + "src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.test.tsx": 619, + "src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx": 162, + "src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx": 1795, + "src/renderer/src/components/settings/ChatNamingSetting.test.tsx": 1784, + "src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx": 1723, + "src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx": 1343, + "src/renderer/src/components/settings/ChatSettingsSection.test.tsx": 1939, + "src/renderer/src/components/settings/CliSection.install-failure.test.tsx": 2113, + "src/renderer/src/components/settings/CliSection.test.tsx": 1847, + "src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx": 1626, + "src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx": 1517, + "src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx": 463, + "src/renderer/src/components/settings/CommitMessageAiPane.test.tsx": 1859, + "src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx": 459, + "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx": 1979, + "src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx": 558, + "src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx": 685, + "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx": 786, + "src/renderer/src/components/settings/EphemeralVmsPane.test.tsx": 7418, + "src/renderer/src/components/settings/ExperimentalPane.test.tsx": 1697, + "src/renderer/src/components/settings/FloatingWorkspacePane.test.tsx": 1674, + "src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx": 1367, + "src/renderer/src/components/settings/GeneralPane.test.ts": 9929, + "src/renderer/src/components/settings/GeneralRemoteServerUpdates.test.tsx": 306, + "src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx": 1953, + "src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx": 1880, + "src/renderer/src/components/settings/GhosttyImportModal.test.ts": 213, + "src/renderer/src/components/settings/GitPane.test.ts": 1505, + "src/renderer/src/components/settings/GitPane.test.tsx": 1506, + "src/renderer/src/components/settings/GrokAccountsSection.test.tsx": 384, + "src/renderer/src/components/settings/HiddenExperimentalGroup.test.tsx": 326, + "src/renderer/src/components/settings/KagiSessionLinkForm.test.ts": 1476, + "src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx": 227, + "src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx": 152, + "src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx": 672, + "src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx": 432, + "src/renderer/src/components/settings/MachineNameField.test.tsx": 531, + "src/renderer/src/components/settings/ManagedServerRow.test.tsx": 401, + "src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx": 1848, + "src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx": 1509, + "src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx": 551, + "src/renderer/src/components/settings/MobilePairingQrSection.test.tsx": 539, + "src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx": 1031, + "src/renderer/src/components/settings/MobilePane.test.tsx": 1213, + "src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx": 635, + "src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx": 2559, + "src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx": 564, + "src/renderer/src/components/settings/NotificationHostToggles.test.tsx": 610, + "src/renderer/src/components/settings/NotificationsPane.test.tsx": 2062, + "src/renderer/src/components/settings/OpenInMenuSetting.test.ts": 233, + "src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx": 516, + "src/renderer/src/components/settings/OrchestrationPane.test.tsx": 1871, + "src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx": 207, + "src/renderer/src/components/settings/PluginConsentDialog.test.tsx": 2096, + "src/renderer/src/components/settings/PluginInstallDialog.test.tsx": 803, + "src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts": 1327, + "src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx": 804, + "src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 533, + "src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 642, + "src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 1128, + "src/renderer/src/components/settings/PrivacyPane.test.ts": 1570, + "src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 709, + "src/renderer/src/components/settings/QuickCommandsList.test.tsx": 604, + "src/renderer/src/components/settings/QuickCommandsPane.test.ts": 1851, + "src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx": 335, + "src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx": 685, + "src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx": 475, + "src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx": 479, + "src/renderer/src/components/settings/RepositoryHooksSection.test.ts": 799, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.settings-entry.test.tsx": 1925, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx": 2009, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx": 1574, + "src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx": 262, + "src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx": 542, + "src/renderer/src/components/settings/RepositoryPane.test.ts": 2984, + "src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx": 258, + "src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts": 466, + "src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx": 415, + "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 120, + "src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 405, + "src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx": 315, + "src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx": 335, + "src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx": 967, + "src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx": 1217, + "src/renderer/src/components/settings/Settings.load-performance.test.ts": 54, + "src/renderer/src/components/settings/SettingsConstants.test.ts": 285, + "src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 797, + "src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx": 622, + "src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 662, + "src/renderer/src/components/settings/SettingsSidebar.test.tsx": 2372, + "src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 832, + "src/renderer/src/components/settings/ShortcutCommandBlock.test.tsx": 1771, + "src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 215, + "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 399, + "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 633, + "src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx": 450, + "src/renderer/src/components/settings/SshTargetCard.test.tsx": 430, + "src/renderer/src/components/settings/SshTargetForm.test.tsx": 1002, + "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 484, + "src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx": 376, + "src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx": 196, + "src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx": 355, + "src/renderer/src/components/settings/TasksPane.test.tsx": 926, + "src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx": 1998, + "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 499, + "src/renderer/src/components/settings/TerminalContrastSetting.test.tsx": 692, + "src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx": 1643, + "src/renderer/src/components/settings/TerminalPane.pwsh.test.ts": 489, + "src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx": 407, + "src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx": 322, + "src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts": 1446, + "src/renderer/src/components/settings/UnsealedCredentialNotice.test.tsx": 313, + "src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx": 945, + "src/renderer/src/components/settings/VoicePane.test.tsx": 902, + "src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx": 552, + "src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx": 1671, + "src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx": 873, + "src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx": 1765, + "src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts": 435, + "src/renderer/src/components/settings/accounts-search.test.ts": 61, + "src/renderer/src/components/settings/agent-availability-settings.test.ts": 297, + "src/renderer/src/components/settings/agent-default-env-draft.test.ts": 60, + "src/renderer/src/components/settings/appearance-interface-summary.test.ts": 321, + "src/renderer/src/components/settings/appearance-search.test.ts": 556, + "src/renderer/src/components/settings/appearance-status-bar-search.test.ts": 117, + "src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts": 109, + "src/renderer/src/components/settings/bitbucket-integration-card.test.tsx": 466, + "src/renderer/src/components/settings/browser-cookie-import-label.test.ts": 150, + "src/renderer/src/components/settings/browser-link-routing-localization.test.ts": 307, + "src/renderer/src/components/settings/browser-search.test.ts": 234, + "src/renderer/src/components/settings/browser-session-host-selection.test.ts": 47, + "src/renderer/src/components/settings/chat-naming-persistence.test.tsx": 3409, + "src/renderer/src/components/settings/chat-settings-unsaved-guard.test.tsx": 1734, + "src/renderer/src/components/settings/cli-install-failure.test.ts": 60, + "src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 414, + "src/renderer/src/components/settings/codex-account-auth-warning.test.ts": 60, + "src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 42, + "src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 164, + "src/renderer/src/components/settings/developer-permissions-search.test.ts": 318, + "src/renderer/src/components/settings/general-editor-search.test.ts": 140, + "src/renderer/src/components/settings/general-preview-tab-search.test.ts": 101, + "src/renderer/src/components/settings/host-scoped-setting-scope.test.ts": 60, + "src/renderer/src/components/settings/integrations-pane-status.test.ts": 45, + "src/renderer/src/components/settings/jira-integration-card.test.tsx": 467, + "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx": 1710, + "src/renderer/src/components/settings/managed-server-copy.test.ts": 85, + "src/renderer/src/components/settings/mobile-network-interface-selection.test.ts": 71, + "src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts": 67, + "src/renderer/src/components/settings/mobile-pane-search.test.ts": 92, + "src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts": 112, + "src/renderer/src/components/settings/notification-host-pending-save.test.tsx": 1929, + "src/renderer/src/components/settings/osc52-clipboard-copy.test.ts": 277, + "src/renderer/src/components/settings/plugin-install-source.test.ts": 297, + "src/renderer/src/components/settings/provider-account-scope.test.ts": 86, + "src/renderer/src/components/settings/provider-account-visibility.test.ts": 59, + "src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx": 248, + "src/renderer/src/components/settings/repository-hook-settings-draft.test.ts": 290, + "src/renderer/src/components/settings/repository-host-setup-options.test.ts": 290, + "src/renderer/src/components/settings/repository-icon-github.test.ts": 62, + "src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts": 429, + "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts": 94, + "src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx": 1730, + "src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx": 377, + "src/renderer/src/components/settings/session-history-status-copy.test.ts": 282, + "src/renderer/src/components/settings/session-search-computer-rollup.test.ts": 45, + "src/renderer/src/components/settings/setting-ownership.test.ts": 96, + "src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts": 187, + "src/renderer/src/components/settings/settings-form-option-filter.test.ts": 69, + "src/renderer/src/components/settings/settings-host-scope.test.ts": 94, + "src/renderer/src/components/settings/settings-project-checkout-target.test.ts": 70, + "src/renderer/src/components/settings/settings-project-list.test.ts": 94, + "src/renderer/src/components/settings/settings-project-regrouping.test.ts": 550, + "src/renderer/src/components/settings/settings-search-keywords.test.ts": 137, + "src/renderer/src/components/settings/settings-search.test.ts": 63, + "src/renderer/src/components/settings/settings-setup-guide-progress-hook.test.tsx": 95, + "src/renderer/src/components/settings/settings-setup-guide-progress.test.ts": 1583, + "src/renderer/src/components/settings/shortcut-binding-list-mutations.test.ts": 57, + "src/renderer/src/components/settings/shortcut-definition-catalog.test.ts": 316, + "src/renderer/src/components/settings/shortcut-groups.test.ts": 143, + "src/renderer/src/components/settings/shortcut-recording-state.test.ts": 51, + "src/renderer/src/components/settings/shortcut-row-visibility.test.ts": 179, + "src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx": 3192, + "src/renderer/src/components/settings/sparse-preset-date.test.ts": 66, + "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 53, + "src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts": 107, + "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 47, + "src/renderer/src/components/settings/ssh-target-draft.test.ts": 66, + "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 114, + "src/renderer/src/components/settings/task-source-setup-state.test.ts": 62, + "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 565, + "src/renderer/src/components/settings/terminal-preview-content.test.ts": 55, + "src/renderer/src/components/settings/terminal-search.test.ts": 304, + "src/renderer/src/components/settings/use-debounced-settings-text-draft.test.ts": 297, + "src/renderer/src/components/settings/use-mac-captured-digit-chords.test.ts": 706, + "src/renderer/src/components/settings/use-published-machine-name.test.tsx": 269, + "src/renderer/src/components/settings/use-repository-hook-settings-draft.test.tsx": 461, + "src/renderer/src/components/settings/use-runtime-environment-catalog.test.tsx": 1802, + "src/renderer/src/components/settings/use-session-search-status.test.tsx": 560, + "src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx": 245, + "src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx": 890, + "src/renderer/src/components/settings/useGhosttyImport.test.ts": 84, + "src/renderer/src/components/settings/useWarpThemeImport.test.ts": 111, + "src/renderer/src/components/settings/worktree-symlink-path-filter.scan-budget.test.ts": 104, + "src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts": 56, + "src/renderer/src/components/setup-guide/SetupGuideModal.mount-gating.test.tsx": 1712, + "src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts": 1636, + "src/renderer/src/components/setup-guide/use-setup-guide-telemetry.test.ts": 57, + "src/renderer/src/components/shared/MacFolderAccessFixDialog.test.tsx": 1061, + "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts": 2003, + "src/renderer/src/components/shared/useDaemonActions.test.tsx": 448, + "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx": 195, + "src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx": 1551, + "src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx": 541, + "src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 317, + "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 59, + "src/renderer/src/components/sidebar/AddRepoDialogChrome.test.tsx": 632, + "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 558, + "src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 228, + "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 492, + "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 659, + "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 141, + "src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx": 1722, + "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 505, + "src/renderer/src/components/sidebar/CacheTimer.test.tsx": 154, + "src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx": 1722, + "src/renderer/src/components/sidebar/CommentMarkdown.github-callout.test.tsx": 2046, + "src/renderer/src/components/sidebar/CommentMarkdown.growing.test.tsx": 2136, + "src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx": 2032, + "src/renderer/src/components/sidebar/CommentMarkdown.ordered-list.test.tsx": 2756, + "src/renderer/src/components/sidebar/CommentMarkdown.test.tsx": 2088, + "src/renderer/src/components/sidebar/CommentMarkdown.word-fade.test.tsx": 1979, + "src/renderer/src/components/sidebar/CommentMermaidBlock.system-theme.test.tsx": 290, + "src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx": 878, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx": 1146, + "src/renderer/src/components/sidebar/DeleteWorktreeDialogFooter.test.tsx": 613, + "src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx": 699, + "src/renderer/src/components/sidebar/FilterToggleRow.test.tsx": 307, + "src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx": 1535, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx": 1917, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx": 2535, + "src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.test.tsx": 1490, + "src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 589, + "src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx": 1727, + "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx": 1115, + "src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx": 1056, + "src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx": 94, + "src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx": 108, + "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx": 561, + "src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx": 194, + "src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx": 510, + "src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx": 425, + "src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 353, + "src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts": 54, + "src/renderer/src/components/sidebar/SetupScriptPromptCardShell.test.tsx": 484, + "src/renderer/src/components/sidebar/Sidebar.test.tsx": 840, + "src/renderer/src/components/sidebar/SidebarAgentsList.test.tsx": 2753, + "src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx": 3074, + "src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx": 456, + "src/renderer/src/components/sidebar/SidebarHeader.test.tsx": 407, + "src/renderer/src/components/sidebar/SidebarNav.test.tsx": 1772, + "src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.test.tsx": 598, + "src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx": 605, + "src/renderer/src/components/sidebar/SidebarToolbar.test.tsx": 392, + "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx": 459, + "src/renderer/src/components/sidebar/StatusIndicator.test.ts": 201, + "src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.test.tsx": 589, + "src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.resolution.test.tsx": 2470, + "src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx": 367, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx": 1879, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx": 2008, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx": 1680, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx": 1665, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx": 304, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx": 387, + "src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx": 379, + "src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx": 826, + "src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx": 339, + "src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx": 1949, + "src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx": 3248, + "src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx": 1908, + "src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx": 1127, + "src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx": 1664, + "src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1698, + "src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx": 1106, + "src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 1239, + "src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 1332, + "src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1162, + "src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1696, + "src/renderer/src/components/sidebar/WorktreeCard.single-reference-details.test.tsx": 1881, + "src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx": 1238, + "src/renderer/src/components/sidebar/WorktreeCard.test.ts": 3227, + "src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx": 1133, + "src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx": 965, + "src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx": 1237, + "src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx": 1319, + "src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx": 977, + "src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx": 418, + "src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.test.tsx": 473, + "src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.test.tsx": 2662, + "src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx": 2731, + "src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx": 2991, + "src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx": 1396, + "src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx": 1913, + "src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx": 387, + "src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx": 1913, + "src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx": 1311, + "src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx": 2062, + "src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 2108, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx": 146, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx": 586, + "src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 2031, + "src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts": 2233, + "src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 2667, + "src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 3532, + "src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 4327, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 3044, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 1805, + "src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx": 2913, + "src/renderer/src/components/sidebar/WorktreeMetaDialog.host-scope.test.tsx": 2087, + "src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx": 6094, + "src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx": 195, + "src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts": 1624, + "src/renderer/src/components/sidebar/WorktreeReferenceStack.test.tsx": 414, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx": 120, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 439, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx": 265, + "src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx": 2342, + "src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx": 450, + "src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx": 460, + "src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts": 84, + "src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts": 139, + "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts": 49, + "src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts": 59, + "src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts": 81, + "src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx": 1815, + "src/renderer/src/components/sidebar/child-workspaces-toggle-target.test.ts": 1879, + "src/renderer/src/components/sidebar/child-workspaces-toggle-visibility.test.ts": 2328, + "src/renderer/src/components/sidebar/clone-defaults.test.ts": 59, + "src/renderer/src/components/sidebar/create-project-defaults.test.ts": 53, + "src/renderer/src/components/sidebar/default-branch-folder-project-root.test.ts": 1426, + "src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts": 1666, + "src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx": 2527, + "src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts": 81, + "src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx": 644, + "src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx": 2640, + "src/renderer/src/components/sidebar/delete-worktree-flow.test.ts": 914, + "src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts": 521, + "src/renderer/src/components/sidebar/delete-worktree-toast.test.ts": 81, + "src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts": 63, + "src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts": 1445, + "src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts": 130, + "src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx": 2194, + "src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts": 91, + "src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx": 1765, + "src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts": 2163, + "src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts": 2015, + "src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts": 1762, + "src/renderer/src/components/sidebar/host-header-drag.test.tsx": 275, + "src/renderer/src/components/sidebar/host-header-menu-items.test.ts": 53, + "src/renderer/src/components/sidebar/host-rename-remove.test.ts": 53, + "src/renderer/src/components/sidebar/host-section-collapse-folders.test.ts": 184, + "src/renderer/src/components/sidebar/host-section-collapse.test.ts": 195, + "src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts": 53, + "src/renderer/src/components/sidebar/host-section-order.test.ts": 47, + "src/renderer/src/components/sidebar/host-section-reveal.test.tsx": 2061, + "src/renderer/src/components/sidebar/host-section-rows.test.ts": 179, + "src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts": 2113, + "src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts": 63, + "src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts": 75, + "src/renderer/src/components/sidebar/kanban-empty-selection-prune-budget.test.tsx": 1098, + "src/renderer/src/components/sidebar/linear-agent-skill-runtime.shell-override.test.ts": 82, + "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 44, + "src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx": 246, + "src/renderer/src/components/sidebar/manual-sort-switch-toast.test.ts": 1537, + "src/renderer/src/components/sidebar/markdown-top-level-blocks.test.ts": 466, + "src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 234, + "src/renderer/src/components/sidebar/natural-worktree-ids.test.ts": 136, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts": 105, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts": 58, + "src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts": 460, + "src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx": 524, + "src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx": 357, + "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts": 92, + "src/renderer/src/components/sidebar/project-filter-reveal.test.ts": 47, + "src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts": 201, + "src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts": 155, + "src/renderer/src/components/sidebar/project-group-header-drop.test.ts": 136, + "src/renderer/src/components/sidebar/project-header-action-selector-lockstep.test.ts": 172, + "src/renderer/src/components/sidebar/project-header-color.test.ts": 281, + "src/renderer/src/components/sidebar/project-header-drag-commit.test.ts": 154, + "src/renderer/src/components/sidebar/project-header-drag-start.test.ts": 159, + "src/renderer/src/components/sidebar/project-header-drag.test.ts": 201, + "src/renderer/src/components/sidebar/project-header-drop.test.ts": 169, "src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts": 53, - "src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts": 65, - "src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts": 55, - "src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts": 58, - "src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts": 155, - "src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts": 1657, - "src/renderer/src/components/sidebar/repo-header-create-state.test.ts": 110, - "src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts": 45, - "src/renderer/src/components/sidebar/sidebar-filter-state.test.ts": 1063, - "src/renderer/src/components/sidebar/sidebar-host-options.test.ts": 109, + "src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts": 51, + "src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts": 54, + "src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts": 56, + "src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts": 143, + "src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts": 1479, + "src/renderer/src/components/sidebar/repo-header-create-state.test.ts": 86, + "src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts": 53, + "src/renderer/src/components/sidebar/sidebar-filter-state.test.ts": 1362, + "src/renderer/src/components/sidebar/sidebar-host-options.test.ts": 119, + "src/renderer/src/components/sidebar/sidebar-lineage-ancestors.test.ts": 220, "src/renderer/src/components/sidebar/sidebar-project-drop.test.ts": 93, - "src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts": 4515, - "src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts": 205, - "src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts": 69, - "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts": 101, - "src/renderer/src/components/sidebar/smart-attention.test.ts": 142, - "src/renderer/src/components/sidebar/smart-sort.test.ts": 85, - "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 33, - "src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts": 39, - "src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts": 60, - "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 1707, - "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 396, - "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 266, - "src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts": 63, - "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 678, - "src/renderer/src/components/sidebar/use-sidebar-feedback-images.test.tsx": 560, - "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx": 2267, - "src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts": 986, - "src/renderer/src/components/sidebar/use-workspace-kanban-drawer-lingering.test.tsx": 606, - "src/renderer/src/components/sidebar/use-workspace-kanban-outside-dismiss.test.ts": 34, - "src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx": 283, - "src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx": 611, - "src/renderer/src/components/sidebar/use-workspace-status-drop.test.ts": 256, - "src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx": 140, - "src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts": 1246, - "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 2448, - "src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx": 79, - "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts": 145, - "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 162, - "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 105, - "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts": 95, - "src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts": 101, - "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 147, - "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 476, - "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 1585, - "src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx": 556, - "src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts": 168, - "src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts": 32, - "src/renderer/src/components/sidebar/visible-worktree-indexes.test.ts": 1479, - "src/renderer/src/components/sidebar/visible-worktrees.test.ts": 3541, - "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 58, - "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 29, + "src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts": 386, + "src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts": 112, + "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts": 129, + "src/renderer/src/components/sidebar/smart-attention-host-observed-end.test.ts": 93, + "src/renderer/src/components/sidebar/smart-attention.test.ts": 136, + "src/renderer/src/components/sidebar/smart-sort.test.ts": 158, + "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 54, + "src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts": 60, + "src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts": 51, + "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 1936, + "src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx": 650, + "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 200, + "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 318, + "src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts": 68, + "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 330, + "src/renderer/src/components/sidebar/use-sidebar-feedback-images.test.tsx": 334, + "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx": 1548, + "src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts": 76, + "src/renderer/src/components/sidebar/use-workspace-kanban-drawer-lingering.test.tsx": 257, + "src/renderer/src/components/sidebar/use-workspace-kanban-outside-dismiss.test.ts": 64, + "src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx": 210, + "src/renderer/src/components/sidebar/use-workspace-reference-details.test.tsx": 1076, + "src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx": 194, + "src/renderer/src/components/sidebar/use-workspace-status-drop.test.ts": 140, + "src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx": 185, + "src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts": 1394, + "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 2686, + "src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx": 148, + "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts": 141, + "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 169, + "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 139, + "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts": 73, + "src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts": 116, + "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 139, + "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 253, + "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 1633, + "src/renderer/src/components/sidebar/useSidebarProjectDrop.os-file-drop.test.tsx": 647, + "src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx": 245, + "src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts": 176, + "src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts": 58, + "src/renderer/src/components/sidebar/visible-worktree-indexes.test.ts": 1791, + "src/renderer/src/components/sidebar/visible-worktrees-manual-lineage-order.test.ts": 1627, + "src/renderer/src/components/sidebar/visible-worktrees.test.ts": 1757, + "src/renderer/src/components/sidebar/workspace-attachment-source-label.test.ts": 80, + "src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts": 1721, + "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 127, + "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 84, "src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 52, - "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 343, - "src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts": 69, - "src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts": 54, - "src/renderer/src/components/sidebar/workspace-kanban-search.test.ts": 100, - "src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts": 253, - "src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts": 48, - "src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts": 142, - "src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts": 41, - "src/renderer/src/components/sidebar/workspace-status.test.ts": 254, - "src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts": 113, - "src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts": 82, - "src/renderer/src/components/sidebar/worktree-agent-host-attribution.test.ts": 36, - "src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts": 75, - "src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts": 163, - "src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts": 613, - "src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts": 98, - "src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx": 455, - "src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts": 1674, - "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx": 777, - "src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx": 292, - "src/renderer/src/components/sidebar/worktree-card-dom-events.test.ts": 428, - "src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts": 49, - "src/renderer/src/components/sidebar/worktree-card-markdown-isolation.test.ts": 555, - "src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts": 35, - "src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts": 33, - "src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts": 51, - "src/renderer/src/components/sidebar/worktree-card-title-display.test.ts": 62, + "src/renderer/src/components/sidebar/workspace-hover-attachments.test.ts": 119, + "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 181, + "src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts": 57, + "src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts": 51, + "src/renderer/src/components/sidebar/workspace-kanban-search.test.ts": 153, + "src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts": 172, + "src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts": 64, + "src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts": 164, + "src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts": 59, + "src/renderer/src/components/sidebar/workspace-reference-detail-read.test.ts": 310, + "src/renderer/src/components/sidebar/workspace-reference-refresh-targets.test.ts": 582, + "src/renderer/src/components/sidebar/workspace-status.test.ts": 151, + "src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts": 165, + "src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts": 126, + "src/renderer/src/components/sidebar/worktree-agent-host-attribution.test.ts": 64, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts": 79, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts": 120, + "src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts": 953, + "src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts": 82, + "src/renderer/src/components/sidebar/worktree-attachment-editing.test.ts": 83, + "src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx": 204, + "src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts": 1707, + "src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts": 1763, + "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx": 520, + "src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx": 172, + "src/renderer/src/components/sidebar/worktree-card-dom-events.test.ts": 158, + "src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts": 45, + "src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts": 65, + "src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts": 65, + "src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts": 64, + "src/renderer/src/components/sidebar/worktree-card-title-display.test.ts": 47, "src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts": 72, - "src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts": 1795, - "src/renderer/src/components/sidebar/worktree-delete-position-scaling.test.ts": 669, - "src/renderer/src/components/sidebar/worktree-delete-request.test.ts": 46, - "src/renderer/src/components/sidebar/worktree-drag-units.test.ts": 194, - "src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts": 466, - "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts": 206, - "src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts": 286, - "src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts": 483, - "src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts": 193, - "src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts": 43, + "src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts": 1033, + "src/renderer/src/components/sidebar/worktree-delete-position-scaling.test.ts": 306, + "src/renderer/src/components/sidebar/worktree-delete-request.test.ts": 52, + "src/renderer/src/components/sidebar/worktree-drag-units.test.ts": 162, + "src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts": 536, + "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts": 171, + "src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts": 182, + "src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts": 179, + "src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts": 126, + "src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts": 52, "src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts": 56, - "src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts": 200, - "src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts": 238, - "src/renderer/src/components/sidebar/worktree-list-groups-imported-worktrees.test.ts": 243, - "src/renderer/src/components/sidebar/worktree-list-groups-lineage-nesting.test.ts": 173, - "src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts": 187, - "src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts": 208, - "src/renderer/src/components/sidebar/worktree-list-groups-pending-creations.test.ts": 365, - "src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts": 238, - "src/renderer/src/components/sidebar/worktree-list-groups-project-groups.test.ts": 226, - "src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts": 181, - "src/renderer/src/components/sidebar/worktree-list-groups-project-order.test.ts": 1153, - "src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts": 221, - "src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts": 845, - "src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts": 57, - "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 240, - "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 319, - "src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.test.ts": 58, - "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 1449, - "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts": 37, - "src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts": 24, - "src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts": 54, - "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx": 1710, - "src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.test.ts": 235, - "src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts": 231, - "src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts": 253, - "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx": 794, - "src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx": 883, - "src/renderer/src/components/sidebar/worktree-list/navigation/use-selection-host-collision.test.tsx": 2056, - "src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx": 754, - "src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx": 2106, - "src/renderer/src/components/sidebar/worktree-list/rows/indentation.test.ts": 65, - "src/renderer/src/components/sidebar/worktree-list/rows/option-dom-host-collision.test.ts": 475, - "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx": 396, - "src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts": 64, - "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts": 2365, - "src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts": 303, - "src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts": 62, - "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts": 496, - "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx": 401, - "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts": 254, - "src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts": 1519, - "src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts": 106, - "src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts": 2148, - "src/renderer/src/components/sidebar/worktree-manual-order.test.ts": 386, - "src/renderer/src/components/sidebar/worktree-meta-updates.test.ts": 76, - "src/renderer/src/components/sidebar/worktree-multi-selection.test.ts": 27, - "src/renderer/src/components/sidebar/worktree-name-suggestions.test.ts": 168, - "src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts": 37, - "src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx": 266, - "src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts": 254, - "src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts": 46, - "src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts": 77, - "src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts": 56, - "src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts": 337, - "src/renderer/src/components/sidebar/worktree-sidebar-reveal-scroll-settle.test.ts": 28, - "src/renderer/src/components/sidebar/worktree-sidebar-reveal.test.ts": 134, - "src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts": 152, - "src/renderer/src/components/sidebar/worktree-sort-label-ordering.test.ts": 172, - "src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts": 47, - "src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts": 164, - "src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts": 43, - "src/renderer/src/components/sidebar/worktree-unnest.test.ts": 103, - "src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts": 52, - "src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx": 443, - "src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx": 770, - "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx": 834, - "src/renderer/src/components/skills/SkillInstallAgentPicker.test.tsx": 869, - "src/renderer/src/components/skills/SkillInstallDialog.test.tsx": 4156, - "src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx": 3656, - "src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx": 1232, - "src/renderer/src/components/skills/SkillInstallWorkspaceCombobox.test.tsx": 965, - "src/renderer/src/components/skills/SkillShareDialog.test.tsx": 2329, - "src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx": 1251, - "src/renderer/src/components/skills/SkillsPage.test.tsx": 4219, - "src/renderer/src/components/skills/skill-agent-filter.test.ts": 202, - "src/renderer/src/components/skills/skill-bundle-name.test.ts": 122, - "src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts": 47, - "src/renderer/src/components/skills/skill-delete-copy.test.ts": 69, - "src/renderer/src/components/skills/skill-delete-selection.test.ts": 229, - "src/renderer/src/components/skills/skill-description-length.test.ts": 52, - "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 67, - "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 137, - "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 673, + "src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts": 204, + "src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts": 185, + "src/renderer/src/components/sidebar/worktree-list-groups-imported-worktrees.test.ts": 206, + "src/renderer/src/components/sidebar/worktree-list-groups-lineage-nesting.test.ts": 220, + "src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts": 176, + "src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts": 195, + "src/renderer/src/components/sidebar/worktree-list-groups-pending-creations.test.ts": 184, + "src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts": 208, + "src/renderer/src/components/sidebar/worktree-list-groups-project-groups.test.ts": 209, + "src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts": 216, + "src/renderer/src/components/sidebar/worktree-list-groups-project-order.test.ts": 873, + "src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts": 190, + "src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts": 420, + "src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts": 53, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 213, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 224, + "src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.test.ts": 53, + "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 1443, + "src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts": 201, + "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts": 58, + "src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts": 39, + "src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts": 50, + "src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx": 1814, + "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx": 1702, + "src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.test.ts": 181, + "src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts": 181, + "src/renderer/src/components/sidebar/worktree-list/navigation/folder-row-host-identity.test.ts": 170, + "src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts": 189, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsx": 517, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx": 754, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx": 528, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-selection-host-collision.test.tsx": 1905, + "src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx": 313, + "src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx": 1640, + "src/renderer/src/components/sidebar/worktree-list/rows/indentation.test.ts": 56, + "src/renderer/src/components/sidebar/worktree-list/rows/option-dom-host-collision.test.ts": 179, + "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx": 259, + "src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts": 59, + "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts": 2225, + "src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts": 235, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts": 94, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts": 274, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx": 274, + "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts": 207, + "src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts": 1414, + "src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts": 152, + "src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts": 1611, + "src/renderer/src/components/sidebar/worktree-manual-order.test.ts": 306, + "src/renderer/src/components/sidebar/worktree-name-suggestions.test.ts": 164, + "src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts": 62, + "src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx": 178, + "src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts": 171, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts": 69, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts": 78, + "src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts": 65, + "src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts": 142, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal-scroll-settle.test.ts": 41, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal.test.ts": 207, + "src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts": 199, + "src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts": 122, + "src/renderer/src/components/sidebar/worktree-sort-label-ordering.test.ts": 159, + "src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts": 62, + "src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts": 184, + "src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts": 52, + "src/renderer/src/components/sidebar/worktree-unnest.test.ts": 90, + "src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts": 64, + "src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx": 371, + "src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx": 343, + "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx": 782, + "src/renderer/src/components/skills/SkillInstallAgentPicker.test.tsx": 690, + "src/renderer/src/components/skills/SkillInstallDialog.test.tsx": 3927, + "src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx": 3175, + "src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx": 903, + "src/renderer/src/components/skills/SkillInstallWorkspaceCombobox.test.tsx": 705, + "src/renderer/src/components/skills/SkillShareDialog.test.tsx": 2543, + "src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx": 665, + "src/renderer/src/components/skills/SkillsPage.test.tsx": 3940, + "src/renderer/src/components/skills/skill-agent-filter.test.ts": 156, + "src/renderer/src/components/skills/skill-bundle-name.test.ts": 233, + "src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts": 55, + "src/renderer/src/components/skills/skill-delete-copy.test.ts": 110, + "src/renderer/src/components/skills/skill-delete-selection.test.ts": 321, + "src/renderer/src/components/skills/skill-description-length.test.ts": 57, + "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 63, + "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 93, + "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 281, "src/renderer/src/components/skills/skill-install-provider-groups.test.ts": 62, - "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 25, - "src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 81, - "src/renderer/src/components/skills/skill-package-digest.test.ts": 46, - "src/renderer/src/components/skills/skill-package-install-risk.test.ts": 55, - "src/renderer/src/components/skills/skill-share-link.test.ts": 36, - "src/renderer/src/components/skills/skill-share-package-selection.test.ts": 45, - "src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 80, - "src/renderer/src/components/skills/skill-share-selection.test.ts": 91, - "src/renderer/src/components/skills/skill-source-inventory.test.ts": 61, - "src/renderer/src/components/skills/skills-filter.test.ts": 113, - "src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx": 386, - "src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx": 2099, - "src/renderer/src/components/star-nag/StarNagToastHost.test.tsx": 984, - "src/renderer/src/components/stats/CodexUsagePane.test.tsx": 840, - "src/renderer/src/components/stats/GrokUsagePane.test.tsx": 1024, - "src/renderer/src/components/stats/UsageBreakdownSection.test.tsx": 622, - "src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx": 858, - "src/renderer/src/components/stats/usage-daily-chart.test.tsx": 668, - "src/renderer/src/components/stats/usage-overview-model.test.ts": 531, - "src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx": 1458, - "src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx": 2493, - "src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts": 40, - "src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx": 620, - "src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx": 1943, - "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx": 1928, - "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts": 50, - "src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx": 809, - "src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx": 485, - "src/renderer/src/components/status-bar/SshStatusSegment.test.ts": 1996, - "src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx": 2381, - "src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx": 794, - "src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx": 1073, - "src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts": 47, - "src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx": 1799, - "src/renderer/src/components/status-bar/icons.test.tsx": 92, - "src/renderer/src/components/status-bar/inline-usage-bars.test.tsx": 754, - "src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts": 79, - "src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 742, - "src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 53, - "src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 1108, - "src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 71, - "src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts": 62, - "src/renderer/src/components/status-bar/resource-manager-worktree-target.test.ts": 53, - "src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts": 27, - "src/renderer/src/components/status-bar/resource-session-bindings.test.ts": 43, - "src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts": 42, - "src/renderer/src/components/status-bar/resource-session-inventory.test.ts": 33, - "src/renderer/src/components/status-bar/resource-session-kill-confirmation.test.ts": 65, - "src/renderer/src/components/status-bar/resource-session-navigation.test.ts": 60, - "src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts": 94, - "src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts": 1536, - "src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts": 61, - "src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts": 40, + "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 49, + "src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 102, + "src/renderer/src/components/skills/skill-package-digest.test.ts": 44, + "src/renderer/src/components/skills/skill-package-install-risk.test.ts": 50, + "src/renderer/src/components/skills/skill-share-link.test.ts": 48, + "src/renderer/src/components/skills/skill-share-package-selection.test.ts": 63, + "src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 95, + "src/renderer/src/components/skills/skill-share-selection.test.ts": 103, + "src/renderer/src/components/skills/skill-source-inventory.test.ts": 60, + "src/renderer/src/components/skills/skills-filter.test.ts": 87, + "src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx": 184, + "src/renderer/src/components/sparse/SparseCheckoutPresetDraftForm.test.tsx": 559, + "src/renderer/src/components/sparse/SparsePresetChooser.test.tsx": 521, + "src/renderer/src/components/sparse/sparse-directory-browse.test.ts": 59, + "src/renderer/src/components/sparse/sparse-directory-entry-input.test.ts": 123, + "src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx": 1675, + "src/renderer/src/components/star-nag/StarNagToastHost.test.tsx": 373, + "src/renderer/src/components/stats/CodexUsagePane.test.tsx": 615, + "src/renderer/src/components/stats/GrokUsagePane.test.tsx": 430, + "src/renderer/src/components/stats/UsageBreakdownSection.test.tsx": 406, + "src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx": 566, + "src/renderer/src/components/stats/usage-daily-chart.test.tsx": 231, + "src/renderer/src/components/stats/usage-overview-model.test.ts": 361, + "src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx": 907, + "src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx": 2014, + "src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx": 602, + "src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx": 2028, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx": 1713, + "src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx": 422, + "src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx": 270, + "src/renderer/src/components/status-bar/SshStatusSegment.test.ts": 1449, + "src/renderer/src/components/status-bar/StatusBarUsageChangeNotices.test.tsx": 561, + "src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx": 1978, + "src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx": 391, + "src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx": 677, + "src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx": 1075, + "src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts": 46, + "src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx": 1775, + "src/renderer/src/components/status-bar/cursor-usage-headline.test.tsx": 282, + "src/renderer/src/components/status-bar/icons.test.tsx": 81, + "src/renderer/src/components/status-bar/inline-usage-bars.test.tsx": 1440, + "src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts": 133, + "src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 651, + "src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 49, + "src/renderer/src/components/status-bar/provider-segment-balance.test.tsx": 1233, + "src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 1225, + "src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 90, + "src/renderer/src/components/status-bar/resource-manager-exit-render-budget.test.tsx": 1982, + "src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts": 58, + "src/renderer/src/components/status-bar/resource-manager-worktree-target.test.ts": 46, + "src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts": 67, + "src/renderer/src/components/status-bar/resource-session-bindings.test.ts": 52, + "src/renderer/src/components/status-bar/resource-session-inventory.test.ts": 80, + "src/renderer/src/components/status-bar/resource-session-kill-confirmation.test.ts": 62, + "src/renderer/src/components/status-bar/resource-session-navigation.test.ts": 61, + "src/renderer/src/components/status-bar/resource-session-removal-render-budget.test.tsx": 352, + "src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts": 100, + "src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts": 1354, + "src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts": 62, + "src/renderer/src/components/status-bar/runtime-host-resources.test.ts": 52, + "src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts": 48, "src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts": 45, - "src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts": 35, - "src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx": 1540, - "src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx": 757, - "src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx": 1039, - "src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts": 35, - "src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts": 1341, - "src/renderer/src/components/status-bar/tooltip.test.ts": 139, - "src/renderer/src/components/status-bar/usage-error-copy.test.ts": 65, - "src/renderer/src/components/status-bar/usage-percentage-label.test.ts": 38, - "src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts": 37, - "src/renderer/src/components/status-bar/usage-roster-formatting.test.ts": 27, - "src/renderer/src/components/status-bar/usage-roster-row-state.test.ts": 53, - "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx": 1022, - "src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx": 472, - "src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx": 823, - "src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx": 448, - "src/renderer/src/components/status-bar/workspace-space-delete-host-routing.test.ts": 43, - "src/renderer/src/components/status-bar/workspace-space-format.test.ts": 97, - "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 51, - "src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts": 64, - "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 217, - "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1511, - "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 1260, - "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 1020, - "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 660, - "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 667, - "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 660, - "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 831, - "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 1389, - "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 1143, - "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 1679, - "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 2690, - "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 1682, - "src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 1585, - "src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 1937, - "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx": 1156, - "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx": 1568, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx": 1865, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx": 2588, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx": 2749, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx": 1967, - "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 1179, - "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 983, - "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 100, - "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 608, - "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx": 313, - "src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts": 44, - "src/renderer/src/components/tab-bar/drop-indicator.test.ts": 50, - "src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts": 68, - "src/renderer/src/components/tab-bar/group-tab-order.test.ts": 1929, - "src/renderer/src/components/tab-bar/middle-button-default-guard.test.ts": 31, - "src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts": 52, - "src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts": 152, - "src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts": 450, - "src/renderer/src/components/tab-bar/open-tab-search.test.ts": 353, - "src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts": 625, - "src/renderer/src/components/tab-bar/query-token-match.test.ts": 48, - "src/renderer/src/components/tab-bar/recent-tab-switching.test.ts": 59, + "src/renderer/src/components/status-bar/status-bar-antigravity-segment.test.tsx": 1543, + "src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts": 54, + "src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx": 961, + "src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx": 1640, + "src/renderer/src/components/status-bar/status-bar-density.test.ts": 61, + "src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx": 1470, + "src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts": 76, + "src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts": 2360, + "src/renderer/src/components/status-bar/status-bar-usage-collapse.test.ts": 184, + "src/renderer/src/components/status-bar/tooltip.test.ts": 167, + "src/renderer/src/components/status-bar/usage-error-copy.test.ts": 52, + "src/renderer/src/components/status-bar/usage-percentage-label.localization.test.ts": 134, + "src/renderer/src/components/status-bar/usage-percentage-label.test.ts": 44, + "src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts": 45, + "src/renderer/src/components/status-bar/usage-roster-formatting.test.ts": 64, + "src/renderer/src/components/status-bar/usage-roster-row-state.test.ts": 54, + "src/renderer/src/components/status-bar/usage-unit-label-parity.test.tsx": 344, + "src/renderer/src/components/status-bar/usage-unit-label.test.ts": 135, + "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx": 1000, + "src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx": 536, + "src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx": 666, + "src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx": 1770, + "src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx": 403, + "src/renderer/src/components/status-bar/workspace-space-delete-host-routing.test.ts": 52, + "src/renderer/src/components/status-bar/workspace-space-format.test.ts": 103, + "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 54, + "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 320, + "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1750, + "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 1122, + "src/renderer/src/components/tab-bar/CopyTabIdMenuItem.test.tsx": 449, + "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 1281, + "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 1810, + "src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx": 746, + "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 437, + "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 329, + "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 849, + "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 2331, + "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 914, + "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 2769, + "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 4026, + "src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx": 1358, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 2501, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 1701, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 2447, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx": 1338, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx": 1434, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx": 1393, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx": 2288, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx": 2607, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx": 2010, + "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 662, + "src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx": 1712, + "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 352, + "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 139, + "src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx": 1749, + "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 313, + "src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx": 592, + "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx": 220, + "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx": 371, + "src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts": 47, + "src/renderer/src/components/tab-bar/drop-indicator.test.ts": 53, + "src/renderer/src/components/tab-bar/group-tab-order.test.ts": 2175, + "src/renderer/src/components/tab-bar/middle-button-default-guard.test.ts": 45, + "src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts": 92, + "src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts": 301, + "src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts": 535, + "src/renderer/src/components/tab-bar/open-tab-search.test.ts": 408, + "src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts": 211, + "src/renderer/src/components/tab-bar/query-token-match.test.ts": 46, + "src/renderer/src/components/tab-bar/recent-tab-switching.test.ts": 142, "src/renderer/src/components/tab-bar/reconcile-order.test.ts": 52, - "src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts": 99, - "src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts": 80, - "src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx": 2562, - "src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx": 42, - "src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts": 1242, - "src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx": 2034, - "src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts": 1823, - "src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts": 93, - "src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts": 33, - "src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts": 158, - "src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts": 1887, - "src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts": 194, - "src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts": 75, - "src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts": 1424, - "src/renderer/src/components/tab-bar/tab-strip-content-resize-observers.test.ts": 236, - "src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts": 73, - "src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx": 1784, - "src/renderer/src/components/tab-bar/tab-strip-scroll-metrics.test.ts": 38, - "src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx": 1090, - "src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts": 131, - "src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts": 53, - "src/renderer/src/components/tab-bar/use-open-tab-search.test.ts": 1977, - "src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts": 425, - "src/renderer/src/components/tab-bar/windows-shell-launch.test.ts": 29, - "src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts": 53, - "src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 528, - "src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts": 35, - "src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 662, - "src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 319, - "src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 110, - "src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts": 527, - "src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 41, - "src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 1253, - "src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 2070, - "src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 38, - "src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 38, - "src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 84, - "src/renderer/src/components/tab-group/tab-insertion.test.ts": 50, - "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 1851, - "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 734, - "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 263, - "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 2028, - "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1358, - "src/renderer/src/components/task-drawer-source-boundary.test.ts": 40, - "src/renderer/src/components/task-page-cache-selectors.test.ts": 102, - "src/renderer/src/components/task-page-checks-pill.test.ts": 31, - "src/renderer/src/components/task-page-default-repo-selection.test.ts": 55, - "src/renderer/src/components/task-page-empty-state.test.ts": 95, - "src/renderer/src/components/task-page-github-dialog-state-authority.test.ts": 376, - "src/renderer/src/components/task-page-github-issue-creation.test.ts": 401, - "src/renderer/src/components/task-page-github-list-scroll-restore.test.ts": 277, + "src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts": 190, + "src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts": 66, + "src/renderer/src/components/tab-bar/tab-bar-item-model.git-status.test.ts": 1809, + "src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts": 1664, + "src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx": 1788, + "src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts": 1647, + "src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts": 226, + "src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts": 53, + "src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts": 312, + "src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts": 1511, + "src/renderer/src/components/tab-bar/tab-create-entry-search-ranking.test.ts": 313, + "src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts": 324, + "src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts": 125, + "src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts": 1394, + "src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts": 1955, + "src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts": 1808, + "src/renderer/src/components/tab-bar/tab-strip-content-resize-observers.test.ts": 171, + "src/renderer/src/components/tab-bar/tab-strip-growth-scroll.test.tsx": 407, + "src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts": 60, + "src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx": 1868, + "src/renderer/src/components/tab-bar/tab-strip-scroll-metrics.test.ts": 67, + "src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx": 1644, + "src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts": 202, + "src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts": 103, + "src/renderer/src/components/tab-bar/use-open-tab-search.test.ts": 1793, + "src/renderer/src/components/tab-bar/use-tab-bar-item-projection.test.tsx": 2025, + "src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.tsx": 367, + "src/renderer/src/components/tab-bar/windows-shell-launch.test.ts": 51, + "src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts": 50, + "src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 360, + "src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts": 43, + "src/renderer/src/components/tab-group/TabGroupPanel.os-file-drop.test.tsx": 1712, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 485, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 241, + "src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 99, + "src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts": 145, + "src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 48, + "src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 1235, + "src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 1438, + "src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 50, + "src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 48, + "src/renderer/src/components/tab-group/tab-group-body-geometry.test.ts": 168, + "src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 74, + "src/renderer/src/components/tab-group/tab-insertion.test.ts": 67, + "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 1883, + "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 998, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 543, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 1900, + "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1213, + "src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts": 1573, + "src/renderer/src/components/task-page-cache-selectors.test.ts": 71, + "src/renderer/src/components/task-page-checks-pill.test.ts": 46, + "src/renderer/src/components/task-page-default-repo-selection.test.ts": 80, + "src/renderer/src/components/task-page-empty-state.test.ts": 89, + "src/renderer/src/components/task-page-github-dialog-state-authority.test.ts": 191, + "src/renderer/src/components/task-page-github-list-scroll-restore.test.ts": 206, "src/renderer/src/components/task-page-github-resume-cache.test.ts": 52, - "src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts": 50, - "src/renderer/src/components/task-page-github-status-actions.test.ts": 52, + "src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts": 75, + "src/renderer/src/components/task-page-github-status-actions.test.ts": 49, "src/renderer/src/components/task-page-github-status-state.test.ts": 56, - "src/renderer/src/components/task-page-github-task-kind.test.ts": 56, - "src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts": 51, - "src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts": 41, - "src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts": 92, - "src/renderer/src/components/task-page-github-work-item-mutations.test.ts": 123, - "src/renderer/src/components/task-page-github-work-item-status.test.ts": 61, - "src/renderer/src/components/task-page-gitlab-task-filters.test.ts": 1702, - "src/renderer/src/components/task-page-initial-selection-scaling.test.tsx": 1727, - "src/renderer/src/components/task-page-jira-cache-selectors.test.ts": 56, - "src/renderer/src/components/task-page-jira-create-fields.test.ts": 51, - "src/renderer/src/components/task-page-jira-grouping.test.ts": 1262, - "src/renderer/src/components/task-page-jira-item-source-context.test.ts": 29, - "src/renderer/src/components/task-page-jira-load-state.test.ts": 55, - "src/renderer/src/components/task-page-jira-project-selection.test.ts": 57, - "src/renderer/src/components/task-page-jira-sort-controls.test.tsx": 932, - "src/renderer/src/components/task-page-jira-sorting.test.ts": 236, - "src/renderer/src/components/task-page-linear-in-orca-issues.test.ts": 79, - "src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts": 35, - "src/renderer/src/components/task-page-linear-issue-empty-state.test.ts": 45, - "src/renderer/src/components/task-page-linear-issue-grouping.test.ts": 966, - "src/renderer/src/components/task-page-linear-issue-request.test.ts": 52, - "src/renderer/src/components/task-page-linear-team-selection.test.ts": 45, - "src/renderer/src/components/task-page-list-chrome-visibility.test.ts": 41, - "src/renderer/src/components/task-page-localized-options.test.ts": 264, - "src/renderer/src/components/task-page-mutation-page-allocation.test.ts": 50, - "src/renderer/src/components/task-page-new-issue-draft.test.ts": 64, - "src/renderer/src/components/task-page-pagination-page-numbers.test.ts": 60, - "src/renderer/src/components/task-page-pr-check-summary.test.ts": 40, - "src/renderer/src/components/task-page-repo-source-context.test.ts": 1554, - "src/renderer/src/components/task-page-repo-source-divergence.test.ts": 1245, - "src/renderer/src/components/task-page-source-switch-boundary.test.ts": 258, - "src/renderer/src/components/task-page-task-creation-drafts.test.ts": 407, - "src/renderer/src/components/task-page-task-source-host-availability.test.ts": 1030, - "src/renderer/src/components/task-page-work-item-pagination.test.ts": 70, - "src/renderer/src/components/task-page-workspace-composer-boundary.test.ts": 463, - "src/renderer/src/components/task-source-context-summary.test.ts": 58, - "src/renderer/src/components/task-source-provider-availability.test.ts": 60, - "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 1022, - "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 466, - "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 2509, - "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 1716, - "src/renderer/src/components/terminal-pane/SessionRestoredBanner.test.tsx": 306, - "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 220, - "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx": 1724, - "src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts": 911, - "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx": 1966, - "src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx": 684, - "src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx": 1403, - "src/renderer/src/components/terminal-pane/TerminalProcessExitOverlay.test.tsx": 513, - "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx": 580, - "src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx": 1805, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts": 217, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts": 147, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts": 170, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts": 104, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts": 135, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts": 186, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts": 168, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts": 170, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts": 119, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts": 102, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts": 181, - "src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts": 966, - "src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts": 217, - "src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts": 112, - "src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts": 123, - "src/renderer/src/components/terminal-pane/agent-hook-terminal-lifecycle.test.ts": 50, - "src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts": 135, - "src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts": 46, - "src/renderer/src/components/terminal-pane/agent-process-inspection-round.test.ts": 48, - "src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts": 52, - "src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 84, - "src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 24, - "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts": 1008, - "src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts": 66, - "src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts": 34, - "src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts": 93, - "src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts": 144, - "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts": 50, - "src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts": 61, - "src/renderer/src/components/terminal-pane/direct-ssh-hidden-output-restore-unavailable-banner.test.ts": 921, - "src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts": 1446, - "src/renderer/src/components/terminal-pane/expand-collapse-render-stability.test.ts": 2192, - "src/renderer/src/components/terminal-pane/expand-collapse.test.ts": 86, - "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts": 43, - "src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts": 47, - "src/renderer/src/components/terminal-pane/git-bash-console-capacity.test.ts": 40, - "src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts": 69, - "src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts": 73, - "src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts": 7285, - "src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts": 220, - "src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts": 657, - "src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts": 769, - "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx": 2192, - "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx": 2539, - "src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx": 2043, - "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts": 1892, - "src/renderer/src/components/terminal-pane/layout-serialization.test.ts": 78, - "src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts": 204, - "src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts": 1568, - "src/renderer/src/components/terminal-pane/merge-captured-leaf-state.test.ts": 39, - "src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts": 29, - "src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts": 50, - "src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts": 50, - "src/renderer/src/components/terminal-pane/mouse-hide-while-typing.test.ts": 53, - "src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts": 83, - "src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts": 389, - "src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts": 621, - "src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts": 130, - "src/renderer/src/components/terminal-pane/override-affected-panes.test.ts": 49, - "src/renderer/src/components/terminal-pane/paired-parking-unverifiable-host.test.ts": 82, - "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts": 2000, - "src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts": 28, - "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts": 513, - "src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts": 50, - "src/renderer/src/components/terminal-pane/pane-helpers.test.ts": 59, - "src/renderer/src/components/terminal-pane/pane-restart-transport-handoff.test.ts": 1459, - "src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts": 39, - "src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx": 3714, - "src/renderer/src/components/terminal-pane/parked-terminal-buffer-capture.test.ts": 183, - "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts": 1531, - "src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts": 130, - "src/renderer/src/components/terminal-pane/parked-terminal-tab-capture-episodes.test.ts": 150, - "src/renderer/src/components/terminal-pane/parse-osc7.test.ts": 56, - "src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts": 1233, - "src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts": 1002, - "src/renderer/src/components/terminal-pane/pending-split-close.test.ts": 1013, - "src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts": 32, - "src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts": 1595, - "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts": 1581, + "src/renderer/src/components/task-page-github-task-kind.test.ts": 70, + "src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts": 47, + "src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts": 62, + "src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts": 98, + "src/renderer/src/components/task-page-github-work-item-mutations.test.ts": 168, + "src/renderer/src/components/task-page-github-work-item-status.test.ts": 58, + "src/renderer/src/components/task-page-gitlab-task-filters.test.ts": 1459, + "src/renderer/src/components/task-page-initial-selection-scaling.test.tsx": 1832, + "src/renderer/src/components/task-page-jira-cache-selectors.test.ts": 49, + "src/renderer/src/components/task-page-jira-create-fields.test.ts": 84, + "src/renderer/src/components/task-page-jira-grouping.test.ts": 577, + "src/renderer/src/components/task-page-jira-item-source-context.test.ts": 60, + "src/renderer/src/components/task-page-jira-load-state.test.ts": 54, + "src/renderer/src/components/task-page-jira-project-selection.test.ts": 49, + "src/renderer/src/components/task-page-jira-search.test.ts": 89, + "src/renderer/src/components/task-page-jira-sort-controls.test.tsx": 702, + "src/renderer/src/components/task-page-jira-sorting.test.ts": 134, + "src/renderer/src/components/task-page-jira-text-fallback-notice.test.tsx": 429, + "src/renderer/src/components/task-page-linear-in-orca-issues.test.ts": 94, + "src/renderer/src/components/task-page-linear-issue-empty-state.test.ts": 47, + "src/renderer/src/components/task-page-linear-issue-grouping.test.ts": 1537, + "src/renderer/src/components/task-page-linear-issue-request.test.ts": 60, + "src/renderer/src/components/task-page-linear-team-selection.test.ts": 48, + "src/renderer/src/components/task-page-list-chrome-visibility.test.ts": 58, + "src/renderer/src/components/task-page-localized-options.test.ts": 256, + "src/renderer/src/components/task-page-mutation-page-allocation.test.ts": 86, + "src/renderer/src/components/task-page-new-issue-draft.test.ts": 60, + "src/renderer/src/components/task-page-pagination-page-numbers.test.ts": 63, + "src/renderer/src/components/task-page-pr-check-summary.test.ts": 45, + "src/renderer/src/components/task-page-repo-source-context.test.ts": 1529, + "src/renderer/src/components/task-page-repo-source-divergence.test.ts": 1378, + "src/renderer/src/components/task-page-task-source-host-availability.test.ts": 1464, + "src/renderer/src/components/task-page-work-item-pagination.test.ts": 84, + "src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx": 1049, + "src/renderer/src/components/task-source-context-summary.test.ts": 92, + "src/renderer/src/components/task-source-provider-availability.test.ts": 64, + "src/renderer/src/components/terminal-agent-tab-shortcut.test.ts": 1561, + "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 388, + "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 181, + "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 1699, + "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 1847, + "src/renderer/src/components/terminal-pane/SessionRestoredBanner.test.tsx": 233, + "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 171, + "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx": 1769, + "src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts": 626, + "src/renderer/src/components/terminal-pane/TerminalOverlaySlot.emptied-reaction.test.tsx": 439, + "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx": 1019, + "src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx": 327, + "src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx": 1492, + "src/renderer/src/components/terminal-pane/TerminalProcessExitOverlay.test.tsx": 370, + "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx": 380, + "src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx": 1832, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts": 223, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts": 206, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts": 219, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts": 196, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts": 204, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts": 341, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts": 251, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts": 270, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts": 191, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts": 175, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts": 227, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts": 208, + "src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts": 1486, + "src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts": 111, + "src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts": 147, + "src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts": 188, + "src/renderer/src/components/terminal-pane/agent-hook-terminal-lifecycle.test.ts": 45, + "src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts": 155, + "src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts": 43, + "src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts": 53, + "src/renderer/src/components/terminal-pane/agent-process-inspection-round.test.ts": 61, + "src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts": 108, + "src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 100, + "src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx": 1667, + "src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.test.ts": 1078, + "src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 31, + "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts": 1928, + "src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx": 2177, + "src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts": 245, + "src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts": 83, + "src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts": 232, + "src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts": 53, + "src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts": 44, + "src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts": 135, + "src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts": 193, + "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts": 75, + "src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts": 85, + "src/renderer/src/components/terminal-pane/direct-ssh-hidden-output-restore-unavailable-banner.test.ts": 1301, + "src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts": 2360, + "src/renderer/src/components/terminal-pane/expand-collapse-render-stability.test.ts": 2055, + "src/renderer/src/components/terminal-pane/expand-collapse.test.ts": 101, + "src/renderer/src/components/terminal-pane/floating-file-link-selection.test.ts": 2261, + "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts": 55, + "src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts": 46, + "src/renderer/src/components/terminal-pane/git-bash-console-capacity.test.ts": 52, + "src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts": 90, + "src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts": 54, + "src/renderer/src/components/terminal-pane/ipc-pty-opencode-model.test.ts": 263, + "src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts": 530, + "src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts": 349, + "src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts": 919, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx": 2146, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx": 1944, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx": 2229, + "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts": 1877, + "src/renderer/src/components/terminal-pane/layout-serialization.test.ts": 96, + "src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts": 263, + "src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts": 1422, + "src/renderer/src/components/terminal-pane/merge-captured-leaf-state.test.ts": 56, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts": 47, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts": 45, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts": 38, + "src/renderer/src/components/terminal-pane/mouse-hide-while-typing.test.ts": 48, + "src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts": 87, + "src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts": 278, + "src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts": 1039, + "src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts": 170, + "src/renderer/src/components/terminal-pane/override-affected-panes.test.ts": 48, + "src/renderer/src/components/terminal-pane/paired-parking-unverifiable-host.test.ts": 93, + "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts": 1915, + "src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts": 45, + "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts": 349, + "src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts": 100, + "src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts": 223, + "src/renderer/src/components/terminal-pane/pane-helpers.test.ts": 73, + "src/renderer/src/components/terminal-pane/pane-restart-transport-handoff.test.ts": 556, + "src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts": 58, + "src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx": 3072, + "src/renderer/src/components/terminal-pane/parked-terminal-buffer-capture.test.ts": 308, + "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts": 2465, + "src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts": 428, + "src/renderer/src/components/terminal-pane/parked-terminal-tab-capture-episodes.test.ts": 301, + "src/renderer/src/components/terminal-pane/parse-osc7.test.ts": 50, + "src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts": 1589, + "src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts": 1223, + "src/renderer/src/components/terminal-pane/pending-split-close.test.ts": 1550, + "src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts": 50, + "src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts": 2046, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts": 1677, "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts": 1145, - "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts": 1253, - "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts": 7780, - "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts": 1637, - "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts": 1762, - "src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts": 642, - "src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts": 1014, - "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts": 1017, - "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts": 1177, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts": 960, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts": 1709, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts": 994, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts": 1595, - "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts": 1457, - "src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts": 1511, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts": 1949, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts": 2482, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts": 2106, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts": 1397, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts": 1974, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts": 1212, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts": 1575, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts": 993, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts": 903, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts": 2255, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts": 796, - "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts": 1097, - "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts": 1520, - "src/renderer/src/components/terminal-pane/pty-connection-hook-idle-arbitration.test.ts": 1109, - "src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts": 1194, - "src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts": 1194, - "src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts": 1082, - "src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts": 1779, - "src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts": 1899, - "src/renderer/src/components/terminal-pane/pty-connection-pi-synchronized-input.test.ts": 595, - "src/renderer/src/components/terminal-pane/pty-connection-post-dispose-restore-termination.test.ts": 829, - "src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts": 6007, - "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts": 1073, - "src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts": 4696, - "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts": 4241, - "src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts": 2097, - "src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-source-grid.test.ts": 852, - "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts": 1509, - "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts": 1105, - "src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts": 676, - "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts": 889, - "src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts": 1647, - "src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts": 1562, - "src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts": 1195, - "src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts": 703, - "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts": 729, - "src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts": 1351, - "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts": 2466, - "src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts": 964, - "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts": 1078, - "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts": 5951, - "src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts": 1837, - "src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts": 1270, - "src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts": 926, - "src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts": 1272, - "src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts": 1294, - "src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts": 37, - "src/renderer/src/components/terminal-pane/pty-connection/disposed-spawn-retention.test.ts": 65, - "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts": 52, - "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.test.ts": 63, - "src/renderer/src/components/terminal-pane/pty-connection/foreground-renderer-risk-scan-tail.test.ts": 74, - "src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts": 33, - "src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts": 993, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts": 1537, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts": 1308, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts": 167, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts": 186, - "src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts": 42, - "src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts": 41, - "src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts": 51, - "src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts": 69, - "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts": 196, - "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts": 71, - "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts": 389, - "src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts": 142, - "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts": 877, - "src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts": 33, - "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer-warn-eviction.test.ts": 55, - "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts": 107, - "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts": 56, - "src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts": 61, - "src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts": 42, - "src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts": 133, - "src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts": 2353, - "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts": 138, - "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts": 49, - "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts": 60, - "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts": 435, - "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts": 310, - "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts": 520, - "src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts": 277, - "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts": 1210, - "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts": 603, - "src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts": 229, - "src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts": 233, - "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts": 363, - "src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts": 258, - "src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts": 189, - "src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts": 459, - "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts": 55, - "src/renderer/src/components/terminal-pane/remote-execution-host-pty.test.ts": 1028, - "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts": 1239, - "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 873, - "src/renderer/src/components/terminal-pane/remote-layout-scrollback-retention.test.ts": 43, - "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 66, - "src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts": 567, - "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1496, - "src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts": 1919, - "src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts": 1098, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts": 119, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts": 2442, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts": 5019, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts": 2214, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts": 79, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts": 1574, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts": 1311, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts": 1933, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts": 3688, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts": 2192, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts": 2020, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts": 1894, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts": 2760, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts": 1849, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts": 2998, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts": 5439, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts": 5313, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts": 1946, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts": 3291, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts": 2620, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts": 2114, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts": 3201, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts": 2981, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts": 2126, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts": 4074, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts": 2942, - "src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts": 3115, - "src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts": 34, - "src/renderer/src/components/terminal-pane/replay-guard.test.ts": 130, - "src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts": 147, - "src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts": 167, - "src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts": 1961, - "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts": 1427, - "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 91, - "src/renderer/src/components/terminal-pane/scan22-ssh-new-tab-disposed-spawn-kill.repro.test.ts": 176, - "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 409, - "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 39, - "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 48, - "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 75, - "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 36, - "src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts": 48, - "src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts": 67, - "src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts": 51, - "src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 43, - "src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 59, - "src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 101, - "src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 599, - "src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 124, - "src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 303, - "src/renderer/src/components/terminal-pane/terminal-attention-surface.test.ts": 1639, - "src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts": 33, - "src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts": 144, - "src/renderer/src/components/terminal-pane/terminal-capped-buffer-retention.test.ts": 379, - "src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx": 1028, - "src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts": 562, - "src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.test.ts": 57, - "src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts": 97, - "src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts": 63, - "src/renderer/src/components/terminal-pane/terminal-cold-park-exempt-flip.react185.test.tsx": 1585, - "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx": 581, - "src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts": 1522, - "src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx": 729, - "src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx": 946, - "src/renderer/src/components/terminal-pane/terminal-cold-park-timer-rearm.test.tsx": 704, - "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx": 587, - "src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts": 54, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts": 1608, + "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts": 7322, + "src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts": 1868, + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts": 2005, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts": 1774, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts": 1819, + "src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts": 1469, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts": 1786, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts": 1772, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts": 1745, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts": 2594, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts": 879, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts": 1378, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts": 2002, + "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts": 2467, + "src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts": 2008, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts": 2262, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts": 1815, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts": 2838, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts": 1760, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts": 2566, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts": 1595, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts": 1664, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts": 914, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts": 1148, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts": 2595, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts": 1107, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts": 1929, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts": 1620, + "src/renderer/src/components/terminal-pane/pty-connection-hook-idle-arbitration.test.ts": 1272, + "src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts": 2067, + "src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts": 1436, + "src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts": 2527, + "src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts": 2233, + "src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts": 1856, + "src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts": 2290, + "src/renderer/src/components/terminal-pane/pty-connection-pi-synchronized-input.test.ts": 922, + "src/renderer/src/components/terminal-pane/pty-connection-post-dispose-restore-termination.test.ts": 1239, + "src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts": 4853, + "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts": 1191, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts": 4133, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts": 2681, + "src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts": 2433, + "src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-source-grid.test.ts": 1154, + "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts": 1895, + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts": 2078, + "src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts": 1232, + "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts": 1812, + "src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts": 2447, + "src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts": 1546, + "src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts": 1242, + "src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts": 1408, + "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts": 950, + "src/renderer/src/components/terminal-pane/pty-connection-ssh-reattach-early-input.test.ts": 1045, + "src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts": 1661, + "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts": 1743, + "src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts": 1856, + "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts": 2005, + "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts": 3182, + "src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts": 2416, + "src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts": 1067, + "src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts": 2445, + "src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts": 1123, + "src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts": 1970, + "src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts": 2271, + "src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts": 132, + "src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-startup-snapshot.test.ts": 65, + "src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts": 54, + "src/renderer/src/components/terminal-pane/pty-connection/disposed-spawn-retention.test.ts": 77, + "src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts": 60, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts": 60, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.test.ts": 99, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-renderer-risk-scan-tail.test.ts": 112, + "src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts": 51, + "src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts": 39, + "src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.test.ts": 139, + "src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts": 69, + "src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.test.ts": 1416, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts": 1490, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts": 1420, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts": 189, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts": 226, + "src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts": 46, + "src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts": 46, + "src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts": 50, + "src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts": 73, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts": 258, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts": 155, + "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts": 857, + "src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts": 160, + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts": 752, + "src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts": 53, + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts": 57, + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts": 130, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer-warn-eviction.test.ts": 69, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts": 115, + "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts": 85, + "src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts": 79, + "src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts": 61, + "src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts": 195, + "src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts": 824, + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts": 551, + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts": 56, + "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts": 73, + "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts": 1160, + "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts": 814, + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts": 1299, + "src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts": 738, + "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts": 2267, + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts": 1185, + "src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts": 484, + "src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts": 500, + "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts": 1113, + "src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts": 591, + "src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts": 773, + "src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts": 539, + "src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts": 185, + "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts": 46, + "src/renderer/src/components/terminal-pane/remote-execution-host-pty.test.ts": 1319, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts": 1795, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 1450, + "src/renderer/src/components/terminal-pane/remote-layout-scrollback-retention.test.ts": 64, + "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 71, + "src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts": 988, + "src/renderer/src/components/terminal-pane/remote-reattach-input-buffer.test.ts": 84, + "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1519, + "src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace-retry.test.ts": 2324, + "src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace.test.ts": 43, + "src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts": 1823, + "src/renderer/src/components/terminal-pane/remote-runtime-input-journal.test.ts": 70, + "src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts": 2091, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts": 105, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts": 3257, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts": 5440, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts": 2376, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts": 70, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts": 1605, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts": 1617, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts": 2219, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts": 3968, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts": 4354, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts": 3437, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts": 1769, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts": 2693, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts": 1553, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts": 3458, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts": 9187, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts": 4890, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts": 2895, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-replay.test.ts": 8016, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts": 3856, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts": 2736, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts": 4764, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts": 3084, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts": 1622, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts": 2962, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts": 3183, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts": 2943, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts": 2889, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts": 5167, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unpublished-host-graph.test.ts": 3061, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts": 3266, + "src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts": 275, + "src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts": 3078, + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts": 10249, + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts": 1906, + "src/renderer/src/components/terminal-pane/remote-snapshot-history-seam.test.ts": 3139, + "src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts": 49, + "src/renderer/src/components/terminal-pane/replay-guard.test.ts": 127, + "src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts": 303, + "src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts": 122, + "src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts": 1708, + "src/renderer/src/components/terminal-pane/request-active-terminal-pane-close.test.ts": 178, + "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts": 1372, + "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 50, + "src/renderer/src/components/terminal-pane/scan22-ssh-new-tab-disposed-spawn-kill.repro.test.ts": 412, + "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 270, + "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 52, + "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 50, + "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 60, + "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 58, + "src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts": 71, + "src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts": 62, + "src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts": 54, + "src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 90, + "src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 74, + "src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 70, + "src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 1153, + "src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 82, + "src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 341, + "src/renderer/src/components/terminal-pane/terminal-attention-surface.test.ts": 1370, + "src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts": 147, + "src/renderer/src/components/terminal-pane/terminal-capped-buffer-retention.test.ts": 646, + "src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts": 54, + "src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx": 834, + "src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts": 325, + "src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.test.ts": 55, + "src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts": 117, + "src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-cold-park-exempt-flip.react185.test.tsx": 1752, + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx": 906, + "src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts": 1436, + "src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx": 556, + "src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx": 890, + "src/renderer/src/components/terminal-pane/terminal-cold-park-timer-rearm.test.tsx": 566, + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx": 583, + "src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts": 69, "src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-context-menu-dismiss.test.ts": 31, - "src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts": 49, - "src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts": 868, - "src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts": 86, - "src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts": 525, - "src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts": 205, - "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts": 73, - "src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts": 346, - "src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts": 56, - "src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts": 411, - "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 72, - "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 115, - "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 46, - "src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts": 52, + "src/renderer/src/components/terminal-pane/terminal-context-menu-dismiss.test.ts": 44, + "src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts": 43, + "src/renderer/src/components/terminal-pane/terminal-copy-shortcut.test.ts": 1732, + "src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts": 1612, + "src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts": 107, + "src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts": 317, + "src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts": 309, + "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts": 61, + "src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts": 117, + "src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts": 436, + "src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts": 568, + "src/renderer/src/components/terminal-pane/terminal-drop-local-workspace.test.ts": 574, + "src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.test.ts": 141, + "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 120, + "src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts": 679, + "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 326, + "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 60, + "src/renderer/src/components/terminal-pane/terminal-drop-source-owner.test.ts": 509, + "src/renderer/src/components/terminal-pane/terminal-drop-transport-owner.test.ts": 338, + "src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts": 50, + "src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.test.ts": 317, "src/renderer/src/components/terminal-pane/terminal-drop-write-failure.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts": 223, - "src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts": 289, - "src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts": 809, - "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts": 195, - "src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts": 85, - "src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts": 73, - "src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts": 70, - "src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts": 354, - "src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts": 23, - "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts": 966, - "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts": 851, - "src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts": 173, - "src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts": 76, - "src/renderer/src/components/terminal-pane/terminal-http-url-extraction.test.ts": 53, - "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts": 57, - "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts": 1182, - "src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts": 281, - "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts": 479, - "src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts": 249, - "src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts": 324, - "src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts": 383, - "src/renderer/src/components/terminal-pane/terminal-ime-forwarder-space-claim.test.ts": 356, - "src/renderer/src/components/terminal-pane/terminal-ime-hangul-syllable-flush.test.ts": 1285, - "src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts": 268, - "src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts": 395, - "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts": 72, - "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts": 546, - "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-selector.test.ts": 104, - "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts": 482, - "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts": 429, - "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts": 703, - "src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts": 989, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts": 4544, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-cancelled-preedit-visibility.test.ts": 606, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-cancel.test.ts": 885, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts": 2544, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-consumed-key-commit.test.ts": 634, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-korean-enter-commit-order.test.ts": 670, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-linux-native-trace-replay.test.ts": 4862, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts": 1276, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-preedit-cell-grid.test.ts": 2107, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts": 897, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts": 965, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts": 899, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts": 900, - "src/renderer/src/components/terminal-pane/terminal-inline-image-da1.test.ts": 118, - "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts": 1508, - "src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts": 46, - "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts": 869, - "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts": 656, - "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts": 683, - "src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts": 40, - "src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts": 2134, - "src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts": 355, - "src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts": 38, - "src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts": 1683, - "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts": 39, + "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts": 127, + "src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts": 93, + "src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts": 880, + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts": 369, + "src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts": 1292, + "src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts": 88, + "src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts": 107, + "src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts": 63, + "src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts": 412, + "src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts": 32, + "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts": 1508, + "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts": 1920, + "src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts": 369, + "src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts": 95, + "src/renderer/src/components/terminal-pane/terminal-http-url-extraction.test.ts": 47, + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts": 58, + "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts": 780, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts": 157, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts": 147, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts": 165, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts": 159, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts": 230, + "src/renderer/src/components/terminal-pane/terminal-ime-forwarder-space-claim.test.ts": 226, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-syllable-flush.test.ts": 688, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts": 211, + "src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts": 147, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts": 77, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts": 174, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-selector.test.ts": 81, + "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts": 266, + "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts": 233, + "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts": 438, + "src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts": 337, + "src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts": 454, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts": 2315, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-cancelled-preedit-visibility.test.ts": 385, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-cancel.test.ts": 462, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts": 1414, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-consumed-key-commit.test.ts": 532, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-korean-enter-commit-order.test.ts": 382, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-linux-native-trace-replay.test.ts": 4041, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts": 876, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-preedit-cell-grid.test.ts": 1043, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts": 420, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts": 586, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts": 550, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts": 728, + "src/renderer/src/components/terminal-pane/terminal-inline-image-da1.test.ts": 123, + "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts": 1406, + "src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts": 53, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts": 786, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts": 562, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts": 634, + "src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts": 47, + "src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts": 2037, + "src/renderer/src/components/terminal-pane/terminal-keyboard-native-chat-cover.test.ts": 1797, + "src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts": 140, + "src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts": 57, + "src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts": 1809, + "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts": 69, "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts": 73, - "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 62, - "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 328, - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 103, - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 111, - "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 1448, - "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 226, - "src/renderer/src/components/terminal-pane/terminal-link-click-behavior.test.ts": 33, - "src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts": 902, - "src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts": 549, - "src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts": 398, - "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts": 263, - "src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts": 659, - "src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts": 384, - "src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts": 642, - "src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts": 478, - "src/renderer/src/components/terminal-pane/terminal-link-provider-link-detection.test.ts": 687, - "src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts": 716, - "src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts": 470, - "src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts": 412, - "src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts": 497, - "src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts": 191, - "src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts": 1437, - "src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts": 296, - "src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts": 66, - "src/renderer/src/components/terminal-pane/terminal-notification-state.test.ts": 960, - "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts": 47, - "src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts": 32, - "src/renderer/src/components/terminal-pane/terminal-pane-attention-subscriptions.test.ts": 64, - "src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts": 51, - "src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts": 504, - "src/renderer/src/components/terminal-pane/terminal-pane-host-state-memo.test.ts": 1306, - "src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts": 1222, - "src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts": 491, - "src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts": 362, - "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts": 289, - "src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts": 38, - "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts": 401, - "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts": 89, - "src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts": 2320, - "src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx": 5213, - "src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts": 254, - "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts": 119, - "src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts": 116, - "src/renderer/src/components/terminal-pane/terminal-park-verdict-worktree-driver-loop.test.tsx": 741, - "src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts": 1367, - "src/renderer/src/components/terminal-pane/terminal-parked-tab-eviction-exemption.test.ts": 298, - "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts": 253, - "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts": 1059, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts": 262, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts": 161, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts": 184, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts": 189, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sole-newborn-exit.test.ts": 316, - "src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts": 2679, - "src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts": 62, - "src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts": 105, - "src/renderer/src/components/terminal-pane/terminal-paste-executor-default-yield.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts": 72, - "src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts": 1530, - "src/renderer/src/components/terminal-pane/terminal-paste-payload-metadata.test.ts": 45, - "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts": 40, - "src/renderer/src/components/terminal-pane/terminal-process-exit-restart.test.ts": 49, - "src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts": 1734, - "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts": 335, - "src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts": 28, - "src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts": 71, + "src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx": 356, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 56, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 57, + "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 190, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 98, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 118, + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 1850, + "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 319, + "src/renderer/src/components/terminal-pane/terminal-link-click-behavior.test.ts": 55, + "src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts": 770, + "src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts": 704, + "src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts": 588, + "src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts": 780, + "src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts": 879, + "src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts": 820, + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts": 297, + "src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts": 767, + "src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts": 645, + "src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts": 485, + "src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts": 737, + "src/renderer/src/components/terminal-pane/terminal-link-provider-link-detection.test.ts": 929, + "src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts": 461, + "src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts": 509, + "src/renderer/src/components/terminal-pane/terminal-link-same-id-ssh-file-host.test.ts": 735, + "src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts": 763, + "src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts": 723, + "src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts": 317, + "src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts": 1358, + "src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts": 159, + "src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-notification-state.test.ts": 1566, + "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-option-punctuation.test.ts": 74, + "src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts": 56, + "src/renderer/src/components/terminal-pane/terminal-pane-attention-subscriptions.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts": 55, + "src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx": 1023, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state-memo.test.ts": 1887, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts": 1520, + "src/renderer/src/components/terminal-pane/terminal-pane-manager-options-gesture.test.ts": 2408, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts": 568, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts": 488, + "src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts": 559, + "src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts": 47, + "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts": 151, + "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts": 87, + "src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts": 1735, + "src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx": 6838, + "src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts": 335, + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts": 118, + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts": 109, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts": 132, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-worktree-driver-loop.test.tsx": 467, + "src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts": 1644, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-eviction-exemption.test.ts": 342, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts": 454, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts": 464, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts": 415, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts": 307, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts": 341, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts": 404, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sole-newborn-exit.test.ts": 368, + "src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts": 2849, + "src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts": 85, + "src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts": 153, + "src/renderer/src/components/terminal-pane/terminal-paste-executor-default-yield.test.ts": 58, + "src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts": 1390, + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts": 58, + "src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts": 70, + "src/renderer/src/components/terminal-pane/terminal-process-exit-restart.test.ts": 42, + "src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts": 1433, + "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts": 155, + "src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts": 57, + "src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts": 61, "src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts": 67, - "src/renderer/src/components/terminal-pane/terminal-render-desync-frame.test.ts": 67, - "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts": 430, - "src/renderer/src/components/terminal-pane/terminal-render-desync-weight-probe.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts": 115, - "src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts": 363, - "src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts": 117, - "src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts": 179, - "src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-retention-churn-pins.test.tsx": 569, - "src/renderer/src/components/terminal-pane/terminal-retention-exempt-growth.test.ts": 1509, - "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts": 2008, - "src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts": 980, - "src/renderer/src/components/terminal-pane/terminal-selection-fit-guard.test.ts": 37, - "src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts": 57, - "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts": 120, - "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts": 133, - "src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts": 85, - "src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts": 90, - "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts": 36, - "src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts": 704, - "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts": 44, - "src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts": 56, - "src/renderer/src/components/terminal-pane/terminal-startup-timing-scheduler.test.ts": 114, - "src/renderer/src/components/terminal-pane/terminal-startup-timing.test.ts": 62, - "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts": 58, - "src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts": 30, - "src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts": 84, - "src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts": 263, - "src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts": 1229, - "src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts": 1570, - "src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts": 160, - "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts": 274, - "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts": 1249, - "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts": 41, - "src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts": 71, - "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts": 65, - "src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts": 1613, - "src/renderer/src/components/terminal-pane/title-agent-identity.test.ts": 35, - "src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts": 1809, - "src/renderer/src/components/terminal-pane/use-mobile-overlay-ticks.perf.test.tsx": 764, - "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts": 291, - "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 587, - "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 45, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 1301, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 1149, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 1211, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 1904, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts": 1363, - "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts": 2216, - "src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx": 627, - "src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts": 50, - "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts": 975, - "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts": 611, - "src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts": 72, - "src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx": 511, - "src/renderer/src/components/terminal-pane/useTerminalFontZoom.test.ts": 340, - "src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts": 74, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts": 77, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts": 142, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts": 52, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts": 62, - "src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts": 82, - "src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx": 994, - "src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx": 1832, - "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.test.ts": 177, - "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts": 241, - "src/renderer/src/components/terminal-scrollback-decoration-eviction.test.ts": 1037, - "src/renderer/src/components/terminal-search-decoration-leak.test.ts": 2437, - "src/renderer/src/components/terminal-search-long-wrapped-line.test.ts": 5608, - "src/renderer/src/components/terminal-search-safe-find.test.ts": 51, - "src/renderer/src/components/terminal-workspace-keydown.test.ts": 822, - "src/renderer/src/components/terminal-workspace-surface-ids.test.tsx": 2751, - "src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts": 55, - "src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx": 1990, - "src/renderer/src/components/terminal/active-terminal-repair.test.ts": 47, - "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts": 1529, - "src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts": 43, - "src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts": 1885, - "src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx": 556, - "src/renderer/src/components/terminal/initial-terminal-wiring.test.ts": 41, - "src/renderer/src/components/terminal/initial-terminal.test.ts": 48, - "src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts": 33, - "src/renderer/src/components/terminal/running-terminal-close-guard.test.ts": 67, - "src/renderer/src/components/terminal/split-group-mount.test.ts": 30, - "src/renderer/src/components/terminal/tab-type-cycle.test.ts": 46, - "src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts": 226, - "src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts": 67, - "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts": 70, - "src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts": 2039, - "src/renderer/src/components/terminal/terminal-provider-snapshot-capability-resettlement.test.ts": 1467, - "src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts": 65, - "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts": 294, - "src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts": 1627, - "src/renderer/src/components/terminal/terminal-tab-actions.test.ts": 322, - "src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts": 178, - "src/renderer/src/components/terminal/unsaved-close-queue.test.ts": 49, - "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx": 750, - "src/renderer/src/components/terminal/use-worktree-files.test.tsx": 359, - "src/renderer/src/components/terminal/window-close-running-work.test.ts": 67, - "src/renderer/src/components/ui/color-picker.test.tsx": 537, - "src/renderer/src/components/ui/popover-wheel-scroll.test.tsx": 931, - "src/renderer/src/components/ui/repo-multi-combobox.test.ts": 461, - "src/renderer/src/components/ui/scroll-area.test.tsx": 420, - "src/renderer/src/components/ui/slider.test.tsx": 848, - "src/renderer/src/components/ui/switch.test.tsx": 180, - "src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx": 1887, - "src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts": 59, - "src/renderer/src/components/use-github-task-search-commit.test.ts": 679, - "src/renderer/src/components/use-task-creation-draft-retention.test.ts": 550, - "src/renderer/src/components/use-task-page-jira-list-effects.test.ts": 2187, - "src/renderer/src/components/use-terminal-create-actions.test.tsx": 444, - "src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx": 1796, - "src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx": 381, - "src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts": 2178, - "src/renderer/src/components/virtualized-list.test.tsx": 788, - "src/renderer/src/components/window-close-request-coordinator.test.ts": 436, - "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx": 1869, - "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx": 2614, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx": 512, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx": 758, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx": 637, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts": 328, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts": 92, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts": 1640, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts": 1286, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts": 126, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx": 2448, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts": 175, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx": 2561, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx": 818, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts": 103, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx": 972, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx": 1353, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts": 115, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts": 68, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx": 1202, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx": 2644, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts": 92, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts": 106, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts": 116, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts": 77, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts": 1354, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx": 1810, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx": 957, - "src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.test.tsx": 737, - "src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx": 786, - "src/renderer/src/components/workspace-surface-projection.test.ts": 57, - "src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 591, - "src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 5202, - "src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 836, - "src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx": 939, - "src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts": 2023, - "src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts": 52, - "src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts": 62, - "src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 334, - "src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 111, - "src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts": 471, - "src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts": 148, - "src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 371, - "src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 77, - "src/renderer/src/hooks/automation-agent-status-entry-change.test.ts": 45, - "src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 169, - "src/renderer/src/hooks/automations-changed-event-attribution.test.ts": 1405, - "src/renderer/src/hooks/composer-branch-selection.test.ts": 54, - "src/renderer/src/hooks/composer-drop-failure-toast.test.ts": 83, - "src/renderer/src/hooks/composer-drop-owner.test.ts": 32, - "src/renderer/src/hooks/composer-drop-result.test.ts": 33, - "src/renderer/src/hooks/composer-native-file-drop.test.ts": 57, - "src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx": 668, - "src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx": 1076, - "src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts": 560, - "src/renderer/src/hooks/composer-state/composer-drop-listener.test.ts": 663, - "src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts": 2276, - "src/renderer/src/hooks/composer-state/draft-target-sync.test.ts": 571, - "src/renderer/src/hooks/composer-state/full-creation-execution.test.ts": 1939, - "src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts": 1517, - "src/renderer/src/hooks/composer-state/full-submit-orchestration.test.ts": 610, - "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1387, - "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1981, - "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 113, - "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 113, - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 108, - "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 40, - "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 54, - "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 1496, - "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 302, - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 63, - "src/renderer/src/hooks/fork-push-warning.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts": 49, + "src/renderer/src/components/terminal-pane/terminal-render-desync-frame.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts": 458, + "src/renderer/src/components/terminal-pane/terminal-render-desync-weight-probe.test.ts": 63, + "src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts": 118, + "src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts": 393, + "src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts": 130, + "src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts": 155, + "src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-retention-churn-pins.test.tsx": 643, + "src/renderer/src/components/terminal-pane/terminal-retention-exempt-growth.test.ts": 2874, + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts": 2218, + "src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts": 1451, + "src/renderer/src/components/terminal-pane/terminal-selection-fit-guard.test.ts": 45, + "src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts": 81, + "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts": 154, + "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts": 132, + "src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts": 72, + "src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts": 209, + "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts": 79, + "src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts": 481, + "src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts": 52, + "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts": 53, + "src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-startup-timing-scheduler.test.ts": 116, + "src/renderer/src/components/terminal-pane/terminal-startup-timing.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index-retention.test.ts": 2034, + "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts": 71, + "src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts": 44, + "src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts": 102, + "src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts": 449, + "src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts": 1895, + "src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts": 1787, + "src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts": 72, + "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts": 369, + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts": 1475, + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts": 75, + "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts": 105, + "src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts": 1473, + "src/renderer/src/components/terminal-pane/title-agent-identity.test.ts": 69, + "src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts": 1577, + "src/renderer/src/components/terminal-pane/use-mobile-overlay-ticks.perf.test.tsx": 337, + "src/renderer/src/components/terminal-pane/use-notification-dispatch-verdict.test.ts": 438, + "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts": 477, + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 495, + "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 47, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 1361, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 1425, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 1495, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 1703, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts": 1618, + "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts": 2584, + "src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx": 441, + "src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts": 77, + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts": 746, + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts": 306, + "src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts": 54, + "src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx": 206, + "src/renderer/src/components/terminal-pane/useTerminalFontZoom.test.ts": 180, + "src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts": 65, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts": 87, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts": 100, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts": 102, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts": 102, + "src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx": 1186, + "src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx": 903, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.test.ts": 128, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts": 182, + "src/renderer/src/components/terminal-scrollback-decoration-eviction.test.ts": 504, + "src/renderer/src/components/terminal-search-decoration-leak.test.ts": 2052, + "src/renderer/src/components/terminal-search-long-wrapped-line.test.ts": 4844, + "src/renderer/src/components/terminal-search-safe-find.test.ts": 49, + "src/renderer/src/components/terminal-split-workspace-surfaces-startup-hold.test.tsx": 174, + "src/renderer/src/components/terminal-startup-hold-placeholder.test.tsx": 636, + "src/renderer/src/components/terminal-workspace-keydown.test.ts": 591, + "src/renderer/src/components/terminal-workspace-surface-ids.test.tsx": 2255, + "src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts": 61, + "src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx": 1667, + "src/renderer/src/components/terminal/active-terminal-repair.test.ts": 57, + "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts": 1773, + "src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts": 51, + "src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts": 1948, + "src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx": 1079, + "src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx": 461, + "src/renderer/src/components/terminal/initial-terminal.test.ts": 60, + "src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts": 57, + "src/renderer/src/components/terminal/running-terminal-close-guard.test.ts": 108, + "src/renderer/src/components/terminal/split-group-mount.test.ts": 67, + "src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts": 2061, + "src/renderer/src/components/terminal/tab-type-cycle.test.ts": 49, + "src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts": 389, + "src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts": 51, + "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts": 122, + "src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts": 1808, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability-resettlement.test.ts": 1356, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts": 77, + "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts": 414, + "src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts": 1714, + "src/renderer/src/components/terminal/terminal-tab-actions-unrouted-close.test.ts": 375, + "src/renderer/src/components/terminal/terminal-tab-actions.test.ts": 441, + "src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts": 440, + "src/renderer/src/components/terminal/unsaved-close-queue.test.ts": 54, + "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx": 309, + "src/renderer/src/components/terminal/use-worktree-files.test.tsx": 349, + "src/renderer/src/components/terminal/window-close-running-work.test.ts": 118, + "src/renderer/src/components/ui/color-picker.test.tsx": 331, + "src/renderer/src/components/ui/popover-wheel-scroll.test.tsx": 436, + "src/renderer/src/components/ui/repo-multi-combobox.test.ts": 204, + "src/renderer/src/components/ui/scroll-area.test.tsx": 249, + "src/renderer/src/components/ui/slider.test.tsx": 606, + "src/renderer/src/components/ui/switch.test.tsx": 102, + "src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx": 1501, + "src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts": 61, + "src/renderer/src/components/use-github-task-search-commit.test.ts": 287, + "src/renderer/src/components/use-jira-create-assignee.test.tsx": 250, + "src/renderer/src/components/use-quick-open-interaction.test.tsx": 1646, + "src/renderer/src/components/use-task-creation-draft-retention.test.ts": 562, + "src/renderer/src/components/use-task-page-gitlab-loading.test.tsx": 1645, + "src/renderer/src/components/use-task-page-jira-list-effects.test.ts": 1835, + "src/renderer/src/components/use-terminal-create-actions.test.tsx": 313, + "src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx": 1581, + "src/renderer/src/components/use-terminal-editor-close-queue.reveal.test.tsx": 646, + "src/renderer/src/components/use-terminal-parking-pass.test.tsx": 1936, + "src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx": 240, + "src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts": 1489, + "src/renderer/src/components/virtualized-list.test.tsx": 391, + "src/renderer/src/components/window-close-request-coordinator.test.ts": 222, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx": 2431, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx": 2563, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx": 266, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx": 389, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx": 349, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts": 267, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts": 70, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts": 1523, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts": 1294, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts": 189, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx": 2157, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts": 124, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx": 2357, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx": 532, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts": 85, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx": 363, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx": 601, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts": 174, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts": 123, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx": 537, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx": 2613, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts": 91, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts": 130, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts": 97, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts": 95, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts": 1542, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx": 3533, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx": 439, + "src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.test.tsx": 390, + "src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx": 376, + "src/renderer/src/components/workspace-surface-projection.test.ts": 97, + "src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 315, + "src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 2544, + "src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 357, + "src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx": 998, + "src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts": 124, + "src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts": 1424, + "src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts": 65, + "src/renderer/src/hooks/agent-auto-ack-targets.test.ts": 1504, + "src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 351, + "src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 187, + "src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts": 454, + "src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts": 237, + "src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 504, + "src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 75, + "src/renderer/src/hooks/automation-agent-status-entry-change.test.ts": 50, + "src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 178, + "src/renderer/src/hooks/automations-changed-event-attribution.test.ts": 1801, + "src/renderer/src/hooks/composer-branch-selection.test.ts": 75, + "src/renderer/src/hooks/composer-drop-failure-toast.test.ts": 92, + "src/renderer/src/hooks/composer-drop-result.test.ts": 47, + "src/renderer/src/hooks/composer-native-file-drop.test.ts": 52, + "src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx": 633, + "src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx": 790, + "src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts": 323, + "src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts": 70, + "src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts": 2007, + "src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx": 639, + "src/renderer/src/hooks/composer-state/draft-target-sync.test.ts": 265, + "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1678, + "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1661, + "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 110, + "src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts": 1364, + "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 79, + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 130, + "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 49, + "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 41, + "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 1605, + "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 183, + "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 72, + "src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts": 355, + "src/renderer/src/hooks/editor-external-watch-selected-host.test.ts": 324, + "src/renderer/src/hooks/editor-runtime-file-watch.test.ts": 55, + "src/renderer/src/hooks/fork-push-warning.test.ts": 65, "src/renderer/src/hooks/installed-agent-skill-discovery-cache.test.ts": 67, - "src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 70, - "src/renderer/src/hooks/installed-agent-skill-runtime-eviction.test.ts": 50, - "src/renderer/src/hooks/installed-agent-skill-verdict.test.ts": 927, - "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 1777, - "src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 339, - "src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 1508, - "src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts": 461, - "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 503, - "src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts": 198, - "src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts": 206, - "src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts": 1662, - "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts": 945, - "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts": 1392, - "src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts": 30, - "src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts": 1618, - "src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts": 207, - "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts": 1063, - "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts": 1734, - "src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts": 361, - "src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts": 57, - "src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 69, - "src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 57, - "src/renderer/src/hooks/ipc-tab-switch.test.ts": 98, - "src/renderer/src/hooks/legacy-worker-terminal-recovery-event.test.ts": 61, - "src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts": 53, - "src/renderer/src/hooks/metadata-request-cache.test.ts": 179, - "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2329, - "src/renderer/src/hooks/modal-return-focus-action.test.ts": 27, + "src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 73, + "src/renderer/src/hooks/installed-agent-skill-runtime-eviction.test.ts": 54, + "src/renderer/src/hooks/installed-agent-skill-verdict.test.ts": 1648, + "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index-retention.test.ts": 2083, + "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 1458, + "src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 563, + "src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 1686, + "src/renderer/src/hooks/ipc-events/agent-status-unavailable.test.ts": 82, + "src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts": 144, + "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts": 487, + "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 535, + "src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts": 293, + "src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts": 323, + "src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts": 1461, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts": 1432, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts": 1473, + "src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.test.ts": 1564, + "src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts": 60, + "src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts": 1416, + "src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts": 308, + "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts": 1295, + "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts": 1629, + "src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts": 2199, + "src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts": 646, + "src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts": 1359, + "src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts": 261, + "src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts": 51, + "src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts": 1461, + "src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 79, + "src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 76, + "src/renderer/src/hooks/ipc-tab-switch.test.ts": 81, + "src/renderer/src/hooks/legacy-worker-terminal-recovery-event.test.ts": 67, + "src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts": 61, + "src/renderer/src/hooks/metadata-request-cache.test.ts": 140, + "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2706, + "src/renderer/src/hooks/modal-return-focus-action.test.ts": 53, "src/renderer/src/hooks/programmatic-scroll-marks.test.ts": 46, - "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 329, - "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 102, - "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 1845, - "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 94, - "src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts": 159, - "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 1341, - "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 56, - "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 1373, - "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 1712, - "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 1572, - "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 1734, - "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 336, - "src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts": 78, - "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 100, - "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 93, - "src/renderer/src/hooks/shortcut-label-cache.test.tsx": 2047, - "src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts": 71, - "src/renderer/src/hooks/structured-session-completion-focus.test.ts": 1246, - "src/renderer/src/hooks/unpaired-device-auth-notification.test.ts": 49, - "src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts": 393, - "src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts": 420, - "src/renderer/src/hooks/use-now.test.ts": 318, - "src/renderer/src/hooks/use-palette-search-evaluation-context.test.ts": 586, - "src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts": 1951, - "src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts": 675, - "src/renderer/src/hooks/use-window-stream-visibility.test.ts": 368, - "src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx": 2208, - "src/renderer/src/hooks/useAgentDetectionTarget.test.ts": 1593, - "src/renderer/src/hooks/useAppMenuPaste.test.tsx": 525, - "src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx": 561, - "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts": 2063, - "src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts": 2020, - "src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts": 1984, - "src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts": 1361, - "src/renderer/src/hooks/useAutoAckViewedAgent.mixed-workspace.test.ts": 1339, - "src/renderer/src/hooks/useAutoAckViewedAgent.test.ts": 1829, - "src/renderer/src/hooks/useAutomationDispatchEvents.test.ts": 716, - "src/renderer/src/hooks/useComposerState-decisions.test.ts": 2092, - "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts": 7756, - "src/renderer/src/hooks/useComposerState-host-retarget.test.ts": 2531, - "src/renderer/src/hooks/useComposerState.integration.test.ts": 3242, - "src/renderer/src/hooks/useDetectedAgents.test.tsx": 1651, - "src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts": 5198, - "src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts": 533, - "src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx": 404, - "src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts": 324, - "src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts": 307, - "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 492, - "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 420, - "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 556, - "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 6914, - "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 1496, - "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 50, - "src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts": 2975, - "src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 2091, - "src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts": 3465, - "src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts": 1791, - "src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts": 1553, - "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts": 3794, - "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts": 1645, - "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts": 2844, - "src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts": 1232, - "src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts": 1339, - "src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts": 1691, - "src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts": 1564, - "src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts": 2045, - "src/renderer/src/hooks/useIpcEvents-client-hosted-browser-rows.test.ts": 2146, - "src/renderer/src/hooks/useIpcEvents-close-routing-active-browser-tab.test.ts": 2721, - "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts": 3181, - "src/renderer/src/hooks/useIpcEvents-close-routing-floating-guest.test.ts": 1386, - "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts": 3166, - "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 1969, - "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 1286, - "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 63, - "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1530, - "src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts": 1613, - "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 344, - "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 1973, - "src/renderer/src/hooks/useIpcEvents-silent-terminal-adoption.test.ts": 1127, - "src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts": 1093, - "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts": 2062, - "src/renderer/src/hooks/useIpcEvents-updater-status.test.ts": 1760, - "src/renderer/src/hooks/useIssueMetadata.test.tsx": 329, - "src/renderer/src/hooks/useLinearProviderConnected.test.tsx": 1480, - "src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 2293, - "src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx": 2393, - "src/renderer/src/hooks/useMetadataListRequest.test.tsx": 555, - "src/renderer/src/hooks/useModalReturnFocus.test.tsx": 1404, - "src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 342, - "src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 582, - "src/renderer/src/hooks/useResetCountdownClock.test.ts": 380, - "src/renderer/src/hooks/useRetiredWorktreeNames.test.ts": 729, - "src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx": 1128, - "src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx": 1648, - "src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts": 1342, - "src/renderer/src/hooks/useSidebarResize.test.ts": 42, - "src/renderer/src/hooks/useSkillFreshness.test.tsx": 461, - "src/renderer/src/hooks/useUnreadDockBadge.test.ts": 2344, - "src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts": 71, - "src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts": 56, - "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.ts": 49, - "src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx": 613, - "src/renderer/src/hooks/viewport-size-change-listener.test.ts": 46, - "src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 81, - "src/renderer/src/hooks/worktree-head-identity-apply.test.ts": 41, - "src/renderer/src/hooks/zoom-routing.test.ts": 195, - "src/renderer/src/i18n/I18nProvider.test.tsx": 2173, - "src/renderer/src/i18n/integration-card-status-localization.test.ts": 257, - "src/renderer/src/i18n/intl-locale.test.ts": 135, - "src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts": 158, - "src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts": 214, - "src/renderer/src/i18n/lazy-locale.test.ts": 299, - "src/renderer/src/i18n/locale-english-regression.test.ts": 1231, - "src/renderer/src/i18n/native-chat-locales.test.ts": 461, - "src/renderer/src/i18n/no-top-level-translate.test.ts": 3326, - "src/renderer/src/i18n/plugin-chrome-allowlist.test.ts": 170, - "src/renderer/src/i18n/pseudo-localization.test.ts": 89, - "src/renderer/src/i18n/relative-duration-copy-localization.test.ts": 810, - "src/renderer/src/i18n/relative-time-format.test.ts": 112, - "src/renderer/src/i18n/runtime-required-catalog.test.ts": 544, - "src/renderer/src/i18n/settings-status-label-localization.test.ts": 1029, - "src/renderer/src/i18n/smart-workspace-jira-locales.test.ts": 598, - "src/renderer/src/i18n/technical-literal-catalog-values.test.ts": 829, - "src/renderer/src/i18n/weekday-names.test.ts": 94, - "src/renderer/src/i18n/worktree-visibility-locales.test.ts": 120, - "src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts": 317, - "src/renderer/src/lazy-modal-mount-state.test.ts": 37, - "src/renderer/src/lazy-use-ref-ratchet.test.ts": 260, - "src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts": 1763, - "src/renderer/src/lib/activate-ai-vault-structured-session.test.ts": 1827, - "src/renderer/src/lib/activate-tab-and-focus-pane.test.ts": 34, - "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts": 818, - "src/renderer/src/lib/active-agent-note-send-focused-session.test.ts": 297, - "src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts": 61, - "src/renderer/src/lib/active-agent-note-send-target-detection.test.ts": 339, - "src/renderer/src/lib/active-view-persist.test.ts": 42, - "src/renderer/src/lib/activity-thread-display.test.ts": 84, - "src/renderer/src/lib/agent-background-session-launch-host.test.ts": 980, - "src/renderer/src/lib/agent-catalog-links.test.ts": 113, - "src/renderer/src/lib/agent-draft-readiness.test.ts": 79, - "src/renderer/src/lib/agent-followup-delivery.test.ts": 18686, - "src/renderer/src/lib/agent-hibernation-confirmation.test.ts": 35, - "src/renderer/src/lib/agent-hibernation-coordinator.test.ts": 1424, - "src/renderer/src/lib/agent-hibernation-output-activity.test.ts": 53, - "src/renderer/src/lib/agent-hibernation-pane-age.test.ts": 43, - "src/renderer/src/lib/agent-hibernation-planner.test.ts": 154, - "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts": 1670, - "src/renderer/src/lib/agent-hibernation-visibility.test.ts": 1583, - "src/renderer/src/lib/agent-launch-caller-permissions.test.ts": 1042, - "src/renderer/src/lib/agent-launch-caller-placement.test.ts": 615, - "src/renderer/src/lib/agent-launch-caller-profile-census.test.ts": 625, - "src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts": 735, - "src/renderer/src/lib/agent-launch-caller-routing.test.ts": 667, - "src/renderer/src/lib/agent-launch-prompt-delivery.test.ts": 72, - "src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts": 1361, - "src/renderer/src/lib/agent-launch-route-input.test.ts": 200, - "src/renderer/src/lib/agent-launch-routing-caller-census.test.ts": 1308, - "src/renderer/src/lib/agent-launch-routing.test.ts": 64, - "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts": 177, - "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts": 188, - "src/renderer/src/lib/agent-paste-draft.test.ts": 610, - "src/renderer/src/lib/agent-picker-search.test.ts": 180, - "src/renderer/src/lib/agent-ready-wait.test.ts": 68, - "src/renderer/src/lib/agent-resume-launch-target.test.ts": 2569, - "src/renderer/src/lib/agent-row-primary-text.test.ts": 65, - "src/renderer/src/lib/agent-row-tool-preview.test.ts": 61, - "src/renderer/src/lib/agent-send-title-status.test.ts": 70, - "src/renderer/src/lib/agent-session-fork-context.test.ts": 49, - "src/renderer/src/lib/agent-session-launch-plan.test.ts": 46, - "src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 135, - "src/renderer/src/lib/agent-skill-nav-install-status.test.ts": 1277, - "src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 1657, - "src/renderer/src/lib/agent-status-connection-ownership.test.ts": 53, - "src/renderer/src/lib/agent-status-count.test.ts": 86, - "src/renderer/src/lib/agent-status-epoch-clock.test.ts": 54, - "src/renderer/src/lib/agent-status-evidence-clock.test.ts": 68, - "src/renderer/src/lib/agent-status-terminal-title.test.ts": 92, - "src/renderer/src/lib/agent-status-worktree-attribution.test.ts": 43, - "src/renderer/src/lib/agent-status.test.ts": 1192, - "src/renderer/src/lib/agent-tab-shortcuts.test.ts": 77, - "src/renderer/src/lib/agent-trust-preflight.test.ts": 71, - "src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 1575, - "src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 226, - "src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts": 231, - "src/renderer/src/lib/ai-vault-resume-command.test.ts": 229, - "src/renderer/src/lib/ai-vault-resume-shell.test.ts": 214, - "src/renderer/src/lib/ai-vault-resume-target.test.ts": 59, - "src/renderer/src/lib/ai-vault-session-drag.test.ts": 58, - "src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts": 58, - "src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts": 246, - "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts": 763, - "src/renderer/src/lib/app-command-dispatch.test.ts": 53, - "src/renderer/src/lib/app-font-family.test.ts": 151, - "src/renderer/src/lib/app-menu-paste.test.ts": 494, - "src/renderer/src/lib/app-menu-selection-actions.test.ts": 465, - "src/renderer/src/lib/automation-session-observer.test.ts": 120, - "src/renderer/src/lib/automation-session-reuse.test.ts": 59, - "src/renderer/src/lib/automation-terminal-ownership.test.ts": 1270, - "src/renderer/src/lib/browser-cookie-import-toast.test.ts": 143, - "src/renderer/src/lib/browser-history-match.performance.test.ts": 185, - "src/renderer/src/lib/browser-history-match.test.ts": 175, - "src/renderer/src/lib/browser-page-conversion-history.test.ts": 64, - "src/renderer/src/lib/browser-page-palette-activation.test.ts": 1954, - "src/renderer/src/lib/browser-palette-page-entries.test.ts": 341, - "src/renderer/src/lib/browser-palette-search.test.ts": 193, - "src/renderer/src/lib/browser-uuid.test.ts": 48, - "src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 2061, - "src/renderer/src/lib/client-creation-action-policy.test.ts": 199, - "src/renderer/src/lib/cmd-j-github-url-lookup.test.ts": 27, - "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 231, - "src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 38, - "src/renderer/src/lib/cmd-j-section-leadership.test.ts": 51, - "src/renderer/src/lib/codex-account-display-label.test.ts": 48, - "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 82, - "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 249, - "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 1603, - "src/renderer/src/lib/codex-session-restart.test.ts": 6689, - "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1587, - "src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 1143, - "src/renderer/src/lib/comment-body-line-count.test.ts": 78, - "src/renderer/src/lib/comment-body-submit-state.test.ts": 77, - "src/renderer/src/lib/composer-issue-command.test.ts": 1677, - "src/renderer/src/lib/composer-submit-cancellation.test.ts": 36, - "src/renderer/src/lib/connection-context.test.ts": 978, - "src/renderer/src/lib/crash-diagnostics.test.ts": 117, - "src/renderer/src/lib/create-untitled-markdown.test.ts": 332, - "src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts": 1574, - "src/renderer/src/lib/desktop-window-chrome.test.ts": 44, - "src/renderer/src/lib/diff-comment-compat.test.ts": 44, - "src/renderer/src/lib/diff-comments-format.test.ts": 64, - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 33, - "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 38, - "src/renderer/src/lib/doc-preview-grants.test.ts": 338, - "src/renderer/src/lib/document-theme.test.ts": 53, - "src/renderer/src/lib/editable-target.test.ts": 436, - "src/renderer/src/lib/editor-file-operation-owner.test.ts": 1407, - "src/renderer/src/lib/editor-font-zoom.test.ts": 67, - "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 508, - "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts": 287, - "src/renderer/src/lib/ensure-simulator-tab.test.ts": 32, - "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts": 29, - "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts": 72, - "src/renderer/src/lib/ephemeral-vm-workspace-target.integration.test.ts": 238, - "src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts": 241, - "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts": 68, - "src/renderer/src/lib/execute-open-editor-path-move.test.ts": 1470, - "src/renderer/src/lib/external-editor-open-capability.test.ts": 40, - "src/renderer/src/lib/feature-education-telemetry.test.ts": 65, - "src/renderer/src/lib/feedback-image-attachments.test.ts": 598, - "src/renderer/src/lib/file-preview-action-visibility.test.tsx": 516, - "src/renderer/src/lib/file-preview.test.ts": 321, - "src/renderer/src/lib/file-search-result-owner.test.ts": 66, - "src/renderer/src/lib/file-search-selection.test.ts": 41, - "src/renderer/src/lib/file-type-icons.test.ts": 130, - "src/renderer/src/lib/find-query-bounds.test.ts": 29, - "src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 1379, - "src/renderer/src/lib/fix-checks-agent-launch.test.ts": 325, - "src/renderer/src/lib/flatten-retained-slice.test.ts": 163, - "src/renderer/src/lib/floating-terminal.test.ts": 43, - "src/renderer/src/lib/floating-workspace-terminal-actions.test.ts": 235, - "src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts": 33, - "src/renderer/src/lib/focus-terminal-tab-surface.test.ts": 81, - "src/renderer/src/lib/folder-workspace-path-status.test.ts": 76, - "src/renderer/src/lib/foreground-terminal-tabs.test.ts": 57, + "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 375, + "src/renderer/src/hooks/remote-workspace-push-status-snapshot-lifetime.test.ts": 2187, + "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 274, + "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 1966, + "src/renderer/src/hooks/remote-workspace-session-merge-local-selection.test.ts": 205, + "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 277, + "src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts": 311, + "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 1919, + "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 51, + "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 1375, + "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 1657, + "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 1560, + "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 1826, + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 394, + "src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts": 70, + "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 114, + "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 99, + "src/renderer/src/hooks/shortcut-label-cache.test.tsx": 2077, + "src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts": 58, + "src/renderer/src/hooks/structured-session-completion-focus.test.ts": 1950, + "src/renderer/src/hooks/unpaired-device-auth-notification.test.ts": 47, + "src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts": 350, + "src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts": 262, + "src/renderer/src/hooks/use-document-dark-theme.test.tsx": 268, + "src/renderer/src/hooks/use-list-multi-selection.test.tsx": 243, + "src/renderer/src/hooks/use-now.test.ts": 250, + "src/renderer/src/hooks/use-os-file-drop-owner.test.tsx": 410, + "src/renderer/src/hooks/use-palette-search-evaluation-context.test.ts": 272, + "src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts": 1506, + "src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts": 434, + "src/renderer/src/hooks/use-window-stream-visibility.test.ts": 274, + "src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx": 1752, + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts": 1369, + "src/renderer/src/hooks/useAppMenuPaste.test.tsx": 225, + "src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx": 270, + "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts": 1687, + "src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts": 1904, + "src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts": 1665, + "src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts": 1914, + "src/renderer/src/hooks/useAutoAckViewedAgent.mixed-workspace.test.ts": 1866, + "src/renderer/src/hooks/useAutoAckViewedAgent.test.ts": 1475, + "src/renderer/src/hooks/useAutomationDispatchEvents.test.ts": 1410, + "src/renderer/src/hooks/useComposerState-decisions.test.ts": 59, + "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts": 118, + "src/renderer/src/hooks/useComposerState-host-retarget.test.ts": 98, + "src/renderer/src/hooks/useComposerState.integration.test.ts": 3120, + "src/renderer/src/hooks/useDetectedAgents.test.tsx": 1507, + "src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts": 745, + "src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts": 517, + "src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx": 272, + "src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts": 376, + "src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts": 579, + "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 590, + "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 219, + "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 224, + "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 1476, + "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 1366, + "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 48, + "src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts": 1906, + "src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts": 3396, + "src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 3402, + "src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts": 4162, + "src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts": 2945, + "src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts": 2132, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts": 10394, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts": 2794, + "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts": 3533, + "src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts": 1596, + "src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts": 2422, + "src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts": 1521, + "src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts": 1398, + "src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts": 1748, + "src/renderer/src/hooks/useIpcEvents-client-hosted-browser-rows.test.ts": 2627, + "src/renderer/src/hooks/useIpcEvents-close-routing-active-browser-tab.test.ts": 3432, + "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts": 2641, + "src/renderer/src/hooks/useIpcEvents-close-routing-floating-guest.test.ts": 1892, + "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts": 4606, + "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 2911, + "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 1437, + "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 72, + "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1595, + "src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts": 2869, + "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 325, + "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 2114, + "src/renderer/src/hooks/useIpcEvents-silent-terminal-adoption.test.ts": 1752, + "src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts": 1524, + "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts": 1746, + "src/renderer/src/hooks/useIpcEvents-updater-status.test.ts": 1988, + "src/renderer/src/hooks/useIssueMetadata.test.tsx": 262, + "src/renderer/src/hooks/useLinearProviderConnected.test.tsx": 1249, + "src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 2312, + "src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx": 1916, + "src/renderer/src/hooks/useMetadataListRequest.test.tsx": 277, + "src/renderer/src/hooks/useModalReturnFocus.test.tsx": 1441, + "src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 199, + "src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 282, + "src/renderer/src/hooks/useResetCountdownClock.test.ts": 303, + "src/renderer/src/hooks/useRetiredWorktreeNames.test.ts": 600, + "src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx": 966, + "src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx": 2576, + "src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts": 2176, + "src/renderer/src/hooks/useSidebarResize.test.ts": 59, + "src/renderer/src/hooks/useSkillFreshness.test.tsx": 273, + "src/renderer/src/hooks/useUnreadDockBadge.test.ts": 1844, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts": 88, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts": 144, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.ts": 53, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.tsx": 260, + "src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx": 226, + "src/renderer/src/hooks/viewport-size-change-listener.test.ts": 56, + "src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 61, + "src/renderer/src/hooks/worktree-head-identity-apply.test.ts": 52, + "src/renderer/src/hooks/zoom-routing.test.ts": 686, + "src/renderer/src/i18n/I18nProvider.test.tsx": 1693, + "src/renderer/src/i18n/intl-locale.test.ts": 148, + "src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts": 105, + "src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts": 101, + "src/renderer/src/i18n/lazy-locale.test.ts": 194, + "src/renderer/src/i18n/locale-english-regression.test.ts": 825, + "src/renderer/src/i18n/native-chat-locales.test.ts": 952, + "src/renderer/src/i18n/plugin-chrome-allowlist.test.ts": 96, + "src/renderer/src/i18n/provider-credit-balance-locales.test.ts": 364, + "src/renderer/src/i18n/pseudo-localization.test.ts": 80, + "src/renderer/src/i18n/relative-duration-copy-localization.test.ts": 693, + "src/renderer/src/i18n/relative-time-format.test.ts": 61, + "src/renderer/src/i18n/runtime-required-catalog.test.ts": 664, + "src/renderer/src/i18n/settings-status-label-localization.test.ts": 288, + "src/renderer/src/i18n/technical-literal-catalog-values.test.ts": 250, + "src/renderer/src/i18n/weekday-names.test.ts": 86, + "src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts": 135, + "src/renderer/src/lazy-modal-mount-state.test.ts": 57, + "src/renderer/src/lib/activate-ai-vault-structured-session-host.test.ts": 1143, + "src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts": 1773, + "src/renderer/src/lib/activate-ai-vault-structured-session.test.ts": 1867, + "src/renderer/src/lib/activate-tab-and-focus-pane.test.ts": 59, + "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts": 966, + "src/renderer/src/lib/active-agent-note-send-focused-session.test.ts": 638, + "src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts": 80, + "src/renderer/src/lib/active-agent-note-send-target-detection.test.ts": 525, + "src/renderer/src/lib/active-view-persist.test.ts": 46, + "src/renderer/src/lib/activity-thread-display.test.ts": 68, + "src/renderer/src/lib/agent-background-session-launch-host.test.ts": 1472, + "src/renderer/src/lib/agent-detection-target-inventory.test.ts": 1465, + "src/renderer/src/lib/agent-draft-readiness.test.ts": 142, + "src/renderer/src/lib/agent-followup-delivery.test.ts": 464, + "src/renderer/src/lib/agent-hibernation-confirmation.test.ts": 48, + "src/renderer/src/lib/agent-hibernation-coordinator.test.ts": 1779, + "src/renderer/src/lib/agent-hibernation-output-activity.test.ts": 48, + "src/renderer/src/lib/agent-hibernation-pane-age.test.ts": 56, + "src/renderer/src/lib/agent-hibernation-planner.test.ts": 207, + "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts": 1423, + "src/renderer/src/lib/agent-hibernation-visibility.test.ts": 1582, + "src/renderer/src/lib/agent-launch-caller-permissions.test.ts": 1006, + "src/renderer/src/lib/agent-launch-caller-placement.test.ts": 1164, + "src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts": 1248, + "src/renderer/src/lib/agent-launch-caller-routing.test.ts": 1132, + "src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts": 47, + "src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts": 1221, + "src/renderer/src/lib/agent-launch-prompt-delivery.test.ts": 82, + "src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts": 1819, + "src/renderer/src/lib/agent-launch-route-input.test.ts": 1635, + "src/renderer/src/lib/agent-launch-route-registered-agents.test.ts": 1571, + "src/renderer/src/lib/agent-launch-routing.test.ts": 83, + "src/renderer/src/lib/agent-launch-tab-publication.test.ts": 1452, + "src/renderer/src/lib/agent-launch-through-host.test.ts": 1481, + "src/renderer/src/lib/agent-message-send.test.ts": 251, + "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts": 175, + "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts": 194, + "src/renderer/src/lib/agent-paste-draft.test.ts": 324, + "src/renderer/src/lib/agent-picker-search.test.ts": 167, + "src/renderer/src/lib/agent-ready-wait.test.ts": 125, + "src/renderer/src/lib/agent-resume-launch-target.test.ts": 3321, + "src/renderer/src/lib/agent-row-primary-text.test.ts": 61, + "src/renderer/src/lib/agent-row-tool-preview.test.ts": 59, + "src/renderer/src/lib/agent-send-title-status.test.ts": 98, + "src/renderer/src/lib/agent-session-fork-context.test.ts": 68, + "src/renderer/src/lib/agent-session-launch-plan.test.ts": 1740, + "src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 143, + "src/renderer/src/lib/agent-skill-nav-install-status.test.ts": 1387, + "src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 1373, + "src/renderer/src/lib/agent-status-connection-ownership.test.ts": 127, + "src/renderer/src/lib/agent-status-count.test.ts": 101, + "src/renderer/src/lib/agent-status-epoch-clock.test.ts": 52, + "src/renderer/src/lib/agent-status-evidence-clock.test.ts": 89, + "src/renderer/src/lib/agent-status-terminal-title.test.ts": 117, + "src/renderer/src/lib/agent-status-worktree-attribution.test.ts": 57, + "src/renderer/src/lib/agent-status.test.ts": 489, + "src/renderer/src/lib/agent-tab-shortcuts.test.ts": 78, + "src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts": 450, + "src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 1762, + "src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 447, + "src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts": 459, + "src/renderer/src/lib/ai-vault-resume-command.test.ts": 412, + "src/renderer/src/lib/ai-vault-resume-shell.test.ts": 466, + "src/renderer/src/lib/ai-vault-resume-target.test.ts": 110, + "src/renderer/src/lib/ai-vault-session-drag.test.ts": 67, + "src/renderer/src/lib/ai-vault-session-fork-startup.test.ts": 413, + "src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts": 64, + "src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts": 423, + "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts": 957, + "src/renderer/src/lib/app-command-dispatch.test.ts": 79, + "src/renderer/src/lib/app-font-family.test.ts": 270, + "src/renderer/src/lib/app-menu-paste.test.ts": 209, + "src/renderer/src/lib/app-menu-selection-actions.test.ts": 122, + "src/renderer/src/lib/automation-session-observer.test.ts": 135, + "src/renderer/src/lib/automation-session-reuse.test.ts": 91, + "src/renderer/src/lib/automation-terminal-ownership.test.ts": 1337, + "src/renderer/src/lib/bounded-generation-map.test.ts": 46, + "src/renderer/src/lib/browser-cookie-import-toast.test.ts": 102, + "src/renderer/src/lib/browser-history-match.performance.test.ts": 344, + "src/renderer/src/lib/browser-history-match.test.ts": 334, + "src/renderer/src/lib/browser-page-conversion-history.test.ts": 59, + "src/renderer/src/lib/browser-page-palette-activation.test.ts": 1521, + "src/renderer/src/lib/browser-palette-page-entries.test.ts": 417, + "src/renderer/src/lib/browser-palette-search.test.ts": 322, + "src/renderer/src/lib/browser-uuid.test.ts": 54, + "src/renderer/src/lib/browser-workspace-source-resolution.test.ts": 48, + "src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 1497, + "src/renderer/src/lib/client-creation-action-policy.test.ts": 298, + "src/renderer/src/lib/cmd-j-github-url-lookup.test.ts": 54, + "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 422, + "src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 55, + "src/renderer/src/lib/cmd-j-section-leadership.test.ts": 77, + "src/renderer/src/lib/codex-account-display-label.test.ts": 55, + "src/renderer/src/lib/codex-account-restart-startup.test.ts": 1537, + "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 81, + "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 379, + "src/renderer/src/lib/codex-session-restart-account-recheck.test.ts": 1182, + "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 1412, + "src/renderer/src/lib/codex-session-restart.test.ts": 3754, + "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1583, + "src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 1535, + "src/renderer/src/lib/comment-body-line-count.test.ts": 115, + "src/renderer/src/lib/comment-body-submit-state.test.ts": 85, + "src/renderer/src/lib/composer-issue-command.test.ts": 1472, + "src/renderer/src/lib/composer-submit-cancellation.test.ts": 54, + "src/renderer/src/lib/connection-context.test.ts": 1611, + "src/renderer/src/lib/crash-diagnostics.test.ts": 136, + "src/renderer/src/lib/create-untitled-markdown.test.ts": 444, + "src/renderer/src/lib/default-creation-host.test.ts": 48, + "src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts": 1617, + "src/renderer/src/lib/desktop-window-chrome.test.ts": 56, + "src/renderer/src/lib/diff-comment-compat.test.ts": 50, + "src/renderer/src/lib/diff-comments-format.test.ts": 47, + "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 56, + "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 70, + "src/renderer/src/lib/doc-preview-grants.test.ts": 370, + "src/renderer/src/lib/document-theme.test.ts": 55, + "src/renderer/src/lib/editable-target.test.ts": 175, + "src/renderer/src/lib/editor-file-operation-owner.test.ts": 1442, + "src/renderer/src/lib/editor-font-zoom.test.ts": 77, + "src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts": 1422, + "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 589, + "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts": 652, + "src/renderer/src/lib/ensure-simulator-tab.test.ts": 47, + "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts": 49, + "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts": 82, + "src/renderer/src/lib/ephemeral-vm-workspace-target.integration.test.ts": 297, + "src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts": 317, + "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts": 102, + "src/renderer/src/lib/execute-open-editor-path-move.test.ts": 1738, + "src/renderer/src/lib/external-editor-open-capability.test.ts": 53, + "src/renderer/src/lib/feature-education-telemetry.test.ts": 63, + "src/renderer/src/lib/feedback-image-attachments.test.ts": 314, + "src/renderer/src/lib/feedback-image-shrink.test.ts": 53, + "src/renderer/src/lib/file-owner-runtime-target.test.ts": 284, + "src/renderer/src/lib/file-preview-action-visibility.test.tsx": 826, + "src/renderer/src/lib/file-preview.test.ts": 588, + "src/renderer/src/lib/file-search-result-owner.test.ts": 56, + "src/renderer/src/lib/file-search-selection.test.ts": 52, + "src/renderer/src/lib/file-search-shortcut-policy.test.ts": 181, + "src/renderer/src/lib/file-type-icons.test.ts": 108, + "src/renderer/src/lib/find-query-bounds.test.ts": 49, + "src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 1532, + "src/renderer/src/lib/fix-checks-agent-launch.test.ts": 500, + "src/renderer/src/lib/flatten-retained-slice.test.ts": 177, + "src/renderer/src/lib/floating-terminal.test.ts": 41, + "src/renderer/src/lib/floating-workspace-panel-reveal.test.ts": 291, + "src/renderer/src/lib/floating-workspace-terminal-actions.test.ts": 449, + "src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts": 68, + "src/renderer/src/lib/focus-terminal-tab-surface.split-focus.test.ts": 131, + "src/renderer/src/lib/focus-terminal-tab-surface.test.ts": 71, + "src/renderer/src/lib/folder-workspace-path-status.test.ts": 108, + "src/renderer/src/lib/foreground-terminal-tabs.test.ts": 60, "src/renderer/src/lib/github-links.test.ts": 69, - "src/renderer/src/lib/github-pr-start-point.test.ts": 78, - "src/renderer/src/lib/github-source-runtime-context.test.ts": 63, - "src/renderer/src/lib/github-work-item-source-lookup.test.ts": 97, - "src/renderer/src/lib/github-work-item-workspace-attachment.test.ts": 31, - "src/renderer/src/lib/gitlab-links.test.ts": 71, - "src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts": 84, - "src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts": 1720, - "src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 1168, - "src/renderer/src/lib/host-mirror-handle-gap-drain.test.ts": 1659, - "src/renderer/src/lib/host-mirror-handle-gap-landed-handle.test.ts": 1250, - "src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts": 4216, - "src/renderer/src/lib/host-mirror-handle-gap-subscription-lifetime.test.ts": 1642, - "src/renderer/src/lib/host-mirror-handle-gap-teardown.test.ts": 1351, - "src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts": 1927, - "src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 1687, - "src/renderer/src/lib/http-link-destinations.test.ts": 207, - "src/renderer/src/lib/http-link-modifier-routing.test.ts": 196, - "src/renderer/src/lib/http-link-routing.test.ts": 206, - "src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts": 57, - "src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 477, - "src/renderer/src/lib/ios-web-platform.test.ts": 45, - "src/renderer/src/lib/ipc-error.test.ts": 50, - "src/renderer/src/lib/jira-source-host.test.ts": 30, - "src/renderer/src/lib/keyboard-layout/detect-option-as-alt.test.ts": 62, - "src/renderer/src/lib/keyboard-layout/input-source-id.test.ts": 50, - "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 36, - "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts": 87, + "src/renderer/src/lib/github-pr-start-point.test.ts": 91, + "src/renderer/src/lib/github-source-runtime-context.test.ts": 55, + "src/renderer/src/lib/github-work-item-source-lookup.test.ts": 93, + "src/renderer/src/lib/github-work-item-workspace-attachment.test.ts": 98, + "src/renderer/src/lib/gitlab-links.test.ts": 64, + "src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts": 139, + "src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts": 1322, + "src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 1644, + "src/renderer/src/lib/host-mirror-handle-gap-drain.test.ts": 1339, + "src/renderer/src/lib/host-mirror-handle-gap-landed-handle.test.ts": 1317, + "src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts": 1612, + "src/renderer/src/lib/host-mirror-handle-gap-snapshot-lifetime.test.ts": 2086, + "src/renderer/src/lib/host-mirror-handle-gap-subscription-lifetime.test.ts": 1885, + "src/renderer/src/lib/host-mirror-handle-gap-teardown.test.ts": 1675, + "src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts": 1490, + "src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 1451, + "src/renderer/src/lib/http-link-destinations.test.ts": 308, + "src/renderer/src/lib/http-link-modifier-routing.test.ts": 307, + "src/renderer/src/lib/http-link-routing.test.ts": 326, + "src/renderer/src/lib/image-blob-png.test.ts": 234, + "src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 299, + "src/renderer/src/lib/ime-overlay-escape.test.tsx": 647, + "src/renderer/src/lib/ime-text-field.test.tsx": 599, + "src/renderer/src/lib/ios-web-platform.test.ts": 53, + "src/renderer/src/lib/ipc-error.test.ts": 53, + "src/renderer/src/lib/jira-source-host.test.ts": 52, + "src/renderer/src/lib/keyboard-layout/detect-option-as-alt.test.ts": 60, + "src/renderer/src/lib/keyboard-layout/input-source-id.test.ts": 52, + "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 52, + "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts": 98, "src/renderer/src/lib/keyboard-layout/option-key-location-state.test.ts": 44, - "src/renderer/src/lib/language-detect.test.ts": 84, - "src/renderer/src/lib/large-text-control-paste.test.ts": 482, - "src/renderer/src/lib/launch-agent-background-session-remote.test.ts": 522, - "src/renderer/src/lib/launch-agent-background-session.test.ts": 367, - "src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts": 596, - "src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts": 777, - "src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts": 1850, - "src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts": 568, - "src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts": 1042, - "src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts": 477, - "src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts": 719, - "src/renderer/src/lib/launch-agent-in-new-tab.test.ts": 1384, - "src/renderer/src/lib/launch-agent-session-continuation.test.ts": 93, - "src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 1070, - "src/renderer/src/lib/launch-ai-vault-session.test.ts": 77, - "src/renderer/src/lib/launch-structured-agent-session.test.ts": 1925, - "src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts": 1859, - "src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 102, - "src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 67, - "src/renderer/src/lib/launch-work-item-direct.test.ts": 760, - "src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 529, - "src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 327, - "src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 487, - "src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts": 400, - "src/renderer/src/lib/lazy-with-retry.test.ts": 342, - "src/renderer/src/lib/left-sidebar-appearance.test.ts": 135, - "src/renderer/src/lib/linear-agent-skill-update-command.test.ts": 949, - "src/renderer/src/lib/linear-board-drag-payload.test.ts": 64, + "src/renderer/src/lib/language-detect.test.ts": 97, + "src/renderer/src/lib/large-text-control-paste.test.ts": 178, + "src/renderer/src/lib/launch-agent-background-session-extra-args.test.ts": 465, + "src/renderer/src/lib/launch-agent-background-session-remote.test.ts": 684, + "src/renderer/src/lib/launch-agent-background-session.test.ts": 556, + "src/renderer/src/lib/launch-agent-execution-context.test.ts": 1461, + "src/renderer/src/lib/launch-agent-fresh-new-tab-host-route.test.ts": 154, + "src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts": 1001, + "src/renderer/src/lib/launch-agent-in-new-tab-fresh-host.test.ts": 1688, + "src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts": 591, + "src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts": 965, + "src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts": 1838, + "src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts": 993, + "src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts": 1042, + "src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts": 1018, + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts": 1018, + "src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts": 337, + "src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts": 1582, + "src/renderer/src/lib/launch-agent-session-continuation.test.ts": 71, + "src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 1111, + "src/renderer/src/lib/launch-ai-vault-session.test.ts": 61, + "src/renderer/src/lib/launch-structured-agent-session.test.ts": 2251, + "src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts": 1691, + "src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 163, + "src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 48, + "src/renderer/src/lib/launch-work-item-direct.test.ts": 1033, + "src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 848, + "src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 200, + "src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 172, + "src/renderer/src/lib/lazy-with-retry.test.ts": 232, + "src/renderer/src/lib/left-sidebar-appearance.test.ts": 314, + "src/renderer/src/lib/linear-agent-skill-update-command.test.ts": 1484, + "src/renderer/src/lib/linear-board-drag-payload.test.ts": 63, "src/renderer/src/lib/linear-issue-context-snapshot.test.ts": 95, - "src/renderer/src/lib/linear-issue-url-lookup.test.ts": 54, - "src/renderer/src/lib/linear-issue-workspace-attachment.test.ts": 34, - "src/renderer/src/lib/linear-issue-workspace-open.test.ts": 73, - "src/renderer/src/lib/linear-linked-work-item.test.ts": 58, - "src/renderer/src/lib/linked-work-item-context.test.ts": 1261, - "src/renderer/src/lib/linked-work-item-provider.test.ts": 1731, - "src/renderer/src/lib/list-row-interaction.test.ts": 249, - "src/renderer/src/lib/local-path-open-guard.test.ts": 96, - "src/renderer/src/lib/local-preflight-context-cache.test.ts": 204, - "src/renderer/src/lib/local-preflight-context.test.ts": 270, - "src/renderer/src/lib/locale-text-collators.test.ts": 108, - "src/renderer/src/lib/manual-terminal-worktree-parking.test.ts": 510, - "src/renderer/src/lib/markdown-comment-blocks.test.ts": 34, - "src/renderer/src/lib/markdown-document-templates.test.ts": 227, - "src/renderer/src/lib/markdown-review-note-copy.test.ts": 45, - "src/renderer/src/lib/markdown-review-notes.test.ts": 47, - "src/renderer/src/lib/migration-unsupported-agent-entry.test.ts": 44, - "src/renderer/src/lib/mobile-terminal-tab-mount.test.ts": 41, - "src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts": 182, - "src/renderer/src/lib/monaco-diff-editor-disposal.test.ts": 50, - "src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 750, - "src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts": 5097, - "src/renderer/src/lib/monaco-languages/register-astro.test.ts": 129, - "src/renderer/src/lib/monaco-languages/register-jsonl.test.ts": 248, - "src/renderer/src/lib/monaco-languages/register-nim.test.ts": 78, - "src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts": 42, - "src/renderer/src/lib/monaco-languages/register-svelte.test.ts": 285, - "src/renderer/src/lib/monaco-languages/register-vue.test.ts": 442, - "src/renderer/src/lib/monaco-languages/textmate-language-registration.test.ts": 64, - "src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts": 232, - "src/renderer/src/lib/monaco-peek-preview-options.test.ts": 988, - "src/renderer/src/lib/monaco-setup-steps.test.ts": 28, - "src/renderer/src/lib/monospace-font-family.test.ts": 53, - "src/renderer/src/lib/mouse-shortcut-dispatch.test.ts": 366, - "src/renderer/src/lib/native-chat-initial-view-mode.test.ts": 51, - "src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts": 226, - "src/renderer/src/lib/native-chat-transcript-readability.test.ts": 49, - "src/renderer/src/lib/nested-repo-selected-paths.test.ts": 45, - "src/renderer/src/lib/new-workspace-composer-repo.test.ts": 38, - "src/renderer/src/lib/new-workspace-create-gates.test.ts": 43, - "src/renderer/src/lib/new-workspace-enter-guard.test.ts": 52, - "src/renderer/src/lib/new-workspace-project-options.test.ts": 72, - "src/renderer/src/lib/new-workspace-ssh-gate.test.ts": 72, - "src/renderer/src/lib/new-workspace.test.ts": 4497, - "src/renderer/src/lib/non-secure-context-crypto.repro.test.ts": 1038, - "src/renderer/src/lib/notes-send-agent-targets.test.ts": 155, - "src/renderer/src/lib/onboarding-project-checklist.test.ts": 114, - "src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts": 138, - "src/renderer/src/lib/open-mobile-emulator-tab.test.ts": 297, - "src/renderer/src/lib/open-tab-occupant-agent.test.ts": 102, - "src/renderer/src/lib/orca-hook-trust.test.ts": 59, - "src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts": 597, - "src/renderer/src/lib/orchestration-setup-state.test.ts": 61, - "src/renderer/src/lib/orchestration-skill-coverage.test.ts": 80, - "src/renderer/src/lib/order-empty-query-worktrees.test.ts": 38, - "src/renderer/src/lib/outside-dismiss-guard.test.ts": 61, - "src/renderer/src/lib/palette-match/cmd-j-ranking-contract.test.ts": 114, - "src/renderer/src/lib/palette-match/match-field-allocation.test.ts": 127, - "src/renderer/src/lib/palette-match/palette-match-core.test.ts": 115, - "src/renderer/src/lib/palette-match/palette-match-performance.test.ts": 10210, - "src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts": 65, - "src/renderer/src/lib/palette-match/palette-ranking.test.ts": 39, - "src/renderer/src/lib/palette-repo-resolution.test.ts": 40, - "src/renderer/src/lib/palette-type-alias-match.test.ts": 29, - "src/renderer/src/lib/pane-agent-evidence.test.ts": 74, - "src/renderer/src/lib/pane-manager/browser-mobile-driver-state.test.ts": 64, - "src/renderer/src/lib/pane-manager/browser-remote-viewer-state.test.ts": 41, - "src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts": 1735, - "src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.test.ts": 69, - "src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts": 40, - "src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts": 56, - "src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts": 56, - "src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts": 62, - "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts": 230, - "src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts": 1047, - "src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts": 45, - "src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts": 60, - "src/renderer/src/lib/pane-manager/pane-divider.test.ts": 74, - "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts": 485, - "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 81, - "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 237, - "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 79, - "src/renderer/src/lib/pane-manager/pane-fit.test.ts": 145, - "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts": 245, - "src/renderer/src/lib/pane-manager/pane-inline-image-recovery.test.ts": 67, - "src/renderer/src/lib/pane-manager/pane-inline-images.test.ts": 102, - "src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts": 42, - "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts": 435, - "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts": 41, - "src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts": 60, - "src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts": 722, - "src/renderer/src/lib/pane-manager/pane-pointer-focus.test.ts": 45, - "src/renderer/src/lib/pane-manager/pane-pty-resize-hold.test.ts": 53, - "src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts": 64, - "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts": 394, - "src/renderer/src/lib/pane-manager/pane-scroll.test.ts": 118, - "src/renderer/src/lib/pane-manager/pane-split-close.test.ts": 65, - "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts": 62, - "src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts": 57, - "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts": 107, - "src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts": 65, - "src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.test.ts": 47, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-ack-credit.test.ts": 95, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-backlog-cap.test.ts": 118, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-disposed-writes.test.ts": 79, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-foreground-refresh.test.ts": 160, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts": 266, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-synchronized-frames.test.ts": 178, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts": 312, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts": 181, - "src/renderer/src/lib/pane-manager/pane-tree-equalization-parity.test.ts": 1669, - "src/renderer/src/lib/pane-manager/pane-tree-equalization-scaling.test.ts": 411, - "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts": 140, - "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts": 53, - "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts": 173, - "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts": 382, - "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts": 423, - "src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts": 48, - "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts": 62, - "src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts": 52, - "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts": 40, - "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts": 187, - "src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts": 39, - "src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts": 487, - "src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts": 62, - "src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts": 66, - "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts": 38, - "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts": 53, - "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-write.test.ts": 41, - "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts": 58, - "src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts": 30, - "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts": 77, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts": 78, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts": 125, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts": 51, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts": 117, - "src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts": 62, - "src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.test.ts": 61, - "src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts": 321, - "src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts": 358, - "src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts": 71, - "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts": 87, - "src/renderer/src/lib/pane-manager/xterm-instance-disposed.test.ts": 131, - "src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts": 4939, + "src/renderer/src/lib/linear-issue-url-lookup.test.ts": 100, + "src/renderer/src/lib/linear-issue-workspace-attachment.test.ts": 88, + "src/renderer/src/lib/linear-issue-workspace-open.test.ts": 144, + "src/renderer/src/lib/linear-linked-work-item.test.ts": 81, + "src/renderer/src/lib/link-source-local-open-owner.test.ts": 295, + "src/renderer/src/lib/linked-work-item-context.test.ts": 67, + "src/renderer/src/lib/linked-work-item-provider.test.ts": 1457, + "src/renderer/src/lib/list-multi-selection.test.ts": 49, + "src/renderer/src/lib/list-row-interaction.test.ts": 141, + "src/renderer/src/lib/local-file-access.test.ts": 1545, + "src/renderer/src/lib/local-path-open-guard.test.ts": 87, + "src/renderer/src/lib/local-preflight-context-cache.test.ts": 397, + "src/renderer/src/lib/local-preflight-context.test.ts": 327, + "src/renderer/src/lib/locale-text-collators.test.ts": 48, + "src/renderer/src/lib/manual-terminal-worktree-parking.test.ts": 182, + "src/renderer/src/lib/markdown-comment-blocks.test.ts": 50, + "src/renderer/src/lib/markdown-document-templates.test.ts": 438, + "src/renderer/src/lib/markdown-review-note-copy.test.ts": 60, + "src/renderer/src/lib/markdown-review-notes-setting.test.ts": 47, + "src/renderer/src/lib/markdown-review-notes.test.ts": 81, + "src/renderer/src/lib/migration-unsupported-agent-entry.test.ts": 45, + "src/renderer/src/lib/mobile-terminal-tab-mount.test.ts": 55, + "src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts": 116, + "src/renderer/src/lib/monaco-diff-editor-disposal.test.ts": 49, + "src/renderer/src/lib/monaco-filename-language.test.ts": 68, + "src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 530, + "src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts": 2807, + "src/renderer/src/lib/monaco-languages/register-astro.test.ts": 152, + "src/renderer/src/lib/monaco-languages/register-jsonl.test.ts": 345, + "src/renderer/src/lib/monaco-languages/register-nim.test.ts": 69, + "src/renderer/src/lib/monaco-languages/register-quarto-adversarial.test.ts": 171, + "src/renderer/src/lib/monaco-languages/register-quarto.test.ts": 153, + "src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts": 50, + "src/renderer/src/lib/monaco-languages/register-svelte.test.ts": 169, + "src/renderer/src/lib/monaco-languages/register-typst.test.ts": 64, + "src/renderer/src/lib/monaco-languages/register-vue.test.ts": 325, + "src/renderer/src/lib/monaco-languages/textmate-language-registration.test.ts": 60, + "src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts": 287, + "src/renderer/src/lib/monaco-peek-preview-options.test.ts": 911, + "src/renderer/src/lib/monaco-setup-steps.test.ts": 58, + "src/renderer/src/lib/monospace-font-family.test.ts": 54, + "src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts": 298, + "src/renderer/src/lib/native-chat-transcript-readability.test.ts": 50, + "src/renderer/src/lib/native-file-drop-rejection-message.test.ts": 89, + "src/renderer/src/lib/nested-repo-selected-paths.test.ts": 50, + "src/renderer/src/lib/new-agent-prompt-outcome.test.ts": 641, + "src/renderer/src/lib/new-workspace-composer-repo.test.ts": 64, + "src/renderer/src/lib/new-workspace-create-gates.test.ts": 54, + "src/renderer/src/lib/new-workspace-enter-guard.test.ts": 53, + "src/renderer/src/lib/new-workspace-project-options.test.ts": 74, + "src/renderer/src/lib/new-workspace-ssh-gate.test.ts": 52, + "src/renderer/src/lib/new-workspace.test.ts": 4504, + "src/renderer/src/lib/non-secure-context-crypto.repro.test.ts": 1464, + "src/renderer/src/lib/notes-send-agent-targets.test.ts": 369, + "src/renderer/src/lib/onboarding-project-checklist.test.ts": 54, + "src/renderer/src/lib/open-agent-message-sender.test.ts": 437, + "src/renderer/src/lib/open-document-in-floating-workspace.test.ts": 272, + "src/renderer/src/lib/open-mobile-emulator-tab.test.ts": 371, + "src/renderer/src/lib/open-tab-occupant-agent.test.ts": 123, + "src/renderer/src/lib/orca-hook-trust.test.ts": 53, + "src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts": 796, + "src/renderer/src/lib/orchestration-setup-state.test.ts": 51, + "src/renderer/src/lib/orchestration-skill-coverage.test.ts": 142, + "src/renderer/src/lib/order-empty-query-worktrees.test.ts": 62, + "src/renderer/src/lib/os-file-drop-cancellation-guard.test.ts": 173, + "src/renderer/src/lib/os-file-drop-preload-integration.test.tsx": 489, + "src/renderer/src/lib/outside-dismiss-guard.test.ts": 50, + "src/renderer/src/lib/paired-web-client-host.test.ts": 405, + "src/renderer/src/lib/palette-match/cmd-j-ranking-contract.test.ts": 83, + "src/renderer/src/lib/palette-match/match-field-allocation.test.ts": 154, + "src/renderer/src/lib/palette-match/palette-match-core.test.ts": 121, + "src/renderer/src/lib/palette-match/palette-match-performance.test.ts": 6685, + "src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts": 80, + "src/renderer/src/lib/palette-match/palette-ranking.test.ts": 63, + "src/renderer/src/lib/palette-repo-resolution.test.ts": 59, + "src/renderer/src/lib/palette-type-alias-match.test.ts": 51, + "src/renderer/src/lib/pane-agent-evidence.test.ts": 92, + "src/renderer/src/lib/pane-manager/browser-mobile-driver-state.test.ts": 61, + "src/renderer/src/lib/pane-manager/browser-remote-viewer-state.test.ts": 43, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts": 1413, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.test.ts": 63, + "src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts": 49, + "src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts": 43, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts": 54, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts": 66, + "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts": 607, + "src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts": 478, + "src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts": 59, + "src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts": 58, + "src/renderer/src/lib/pane-manager/pane-divider.test.ts": 78, + "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts": 274, + "src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync-lifecycle.test.ts": 482, + "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 118, + "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 235, + "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 105, + "src/renderer/src/lib/pane-manager/pane-fit.test.ts": 139, + "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts": 197, + "src/renderer/src/lib/pane-manager/pane-inline-image-recovery.test.ts": 76, + "src/renderer/src/lib/pane-manager/pane-inline-images.test.ts": 97, + "src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts": 50, + "src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.test.ts": 384, + "src/renderer/src/lib/pane-manager/pane-layout-gesture.test.ts": 228, + "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts": 243, + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts": 70, + "src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts": 458, + "src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts": 63, + "src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts": 387, + "src/renderer/src/lib/pane-manager/pane-pointer-focus.test.ts": 52, + "src/renderer/src/lib/pane-manager/pane-pty-resize-hold.test.ts": 47, + "src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts": 62, + "src/renderer/src/lib/pane-manager/pane-reveal-lifetime.test.ts": 1002, + "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts": 350, + "src/renderer/src/lib/pane-manager/pane-scroll.test.ts": 179, + "src/renderer/src/lib/pane-manager/pane-split-close.test.ts": 67, + "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts": 85, + "src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts": 54, + "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts": 108, + "src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts": 82, + "src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.test.ts": 52, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-ack-credit.test.ts": 167, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-backlog-cap.test.ts": 160, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-disposed-writes.test.ts": 102, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-foreground-refresh.test.ts": 158, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts": 279, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-synchronized-frames.test.ts": 190, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts": 324, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts": 208, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-parity.test.ts": 676, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-scaling.test.ts": 200, + "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts": 148, + "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts": 80, + "src/renderer/src/lib/pane-manager/pane-viewport-present-lifetime.test.ts": 779, + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts": 124, + "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts": 159, + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts": 356, + "src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts": 73, + "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts": 53, + "src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts": 75, + "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts": 43, + "src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts": 283, + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts": 217, + "src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts": 47, + "src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts": 206, + "src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts": 63, + "src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts": 83, + "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts": 63, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts": 56, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-write.test.ts": 56, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts": 43, + "src/renderer/src/lib/pane-manager/terminal-mouse-encoding-tracker.test.ts": 126, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts": 60, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts": 59, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts": 71, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts": 106, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts": 56, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts": 93, + "src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts": 76, + "src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.test.ts": 55, + "src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts": 365, + "src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts": 196, + "src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts": 73, + "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts": 76, + "src/renderer/src/lib/pane-manager/xterm-instance-disposed.test.ts": 86, + "src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts": 2444, "src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts": 72, - "src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 187, - "src/renderer/src/lib/passive-macos-app-data-access.test.ts": 26, - "src/renderer/src/lib/paste-payload-metadata.test.ts": 51, - "src/renderer/src/lib/path-head-elision.test.ts": 45, - "src/renderer/src/lib/path.test.ts": 42, - "src/renderer/src/lib/pending-worktree-creation.test.ts": 46, - "src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts": 1695, - "src/renderer/src/lib/pi-session-resume-wake.test.ts": 1680, - "src/renderer/src/lib/plugin-command-execution.test.ts": 480, - "src/renderer/src/lib/plugin-command-keybindings.test.ts": 100, - "src/renderer/src/lib/pr-bot-author-overrides.test.ts": 298, - "src/renderer/src/lib/pr-comment-action-state.test.ts": 46, - "src/renderer/src/lib/pr-comment-reactions.test.ts": 30, - "src/renderer/src/lib/primary-selection-paste.test.ts": 551, - "src/renderer/src/lib/primary-selection.test.ts": 67, - "src/renderer/src/lib/project-clone-url-prefill.test.ts": 63, - "src/renderer/src/lib/project-host-clone-url.test.ts": 39, - "src/renderer/src/lib/project-host-setup-options.test.ts": 182, - "src/renderer/src/lib/project-host-workspace-target.test.ts": 105, - "src/renderer/src/lib/project-skill-runtime.test.ts": 87, - "src/renderer/src/lib/provisioned-root-create-options.test.ts": 42, - "src/renderer/src/lib/quick-workspace-agent-selection.test.ts": 143, - "src/renderer/src/lib/react-commit-cascade-install-order.test.ts": 31, - "src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx": 465, - "src/renderer/src/lib/react-commit-cascade-observer.test.ts": 187, - "src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts": 55, - "src/renderer/src/lib/react-commit-cascade-telemetry.test.ts": 223, - "src/renderer/src/lib/react-error-boundary-reporting.test.ts": 43, - "src/renderer/src/lib/react-grab-dev-gate.test.ts": 39, - "src/renderer/src/lib/react-renderer-root.test.ts": 40, - "src/renderer/src/lib/recent-workspace-tab-rows.test.ts": 81, - "src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts": 1606, - "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 136, - "src/renderer/src/lib/renderer-app-platform.test.ts": 338, - "src/renderer/src/lib/renderer-memory-profile.test.ts": 40, - "src/renderer/src/lib/repo-display-labels.test.ts": 55, - "src/renderer/src/lib/repo-runtime-owner.test.ts": 77, - "src/renderer/src/lib/repo-search.test.ts": 57, - "src/renderer/src/lib/repo-slug-cache.test.ts": 62, - "src/renderer/src/lib/repo-slug-index.test.ts": 2511, - "src/renderer/src/lib/resolved-worktree-execution-host.test.ts": 127, - "src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts": 1502, - "src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts": 1425, - "src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 1617, - "src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts": 1603, - "src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 1351, - "src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 1692, - "src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 1627, - "src/renderer/src/lib/resume-sleeping-agent-session.test.ts": 5799, - "src/renderer/src/lib/resume-stale-structured-agent-session.test.ts": 1670, - "src/renderer/src/lib/right-sidebar-visibility.test.ts": 30, - "src/renderer/src/lib/run-quick-command-in-new-tab.test.ts": 106, - "src/renderer/src/lib/running-agent-targets.test.ts": 61, - "src/renderer/src/lib/runtime-host-unverifiable-probe-readers.test.ts": 317, - "src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts": 67, - "src/renderer/src/lib/runtime-session-mirror-targets.test.ts": 67, - "src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts": 64, - "src/renderer/src/lib/runtime-workspace-file-route.test.ts": 140, - "src/renderer/src/lib/screen-submit-shortcut.test.ts": 50, - "src/renderer/src/lib/script-textarea-rows.test.ts": 123, - "src/renderer/src/lib/scroll-cache.test.ts": 51, - "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts": 1709, - "src/renderer/src/lib/session-write-subscriber-allocation.test.ts": 215, - "src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts": 1599, - "src/renderer/src/lib/session-write-subscriber.test.ts": 1398, - "src/renderer/src/lib/settled-worker-wake-policy.test.ts": 1859, - "src/renderer/src/lib/setup-runner.test.ts": 39, - "src/renderer/src/lib/setup-script-prompt.test.ts": 215, - "src/renderer/src/lib/sha256.test.ts": 46, - "src/renderer/src/lib/shutdown-checkpoint-guard.test.ts": 523, - "src/renderer/src/lib/sidebar-worktree-activation.test.ts": 91, - "src/renderer/src/lib/simulator-launch-coordination.test.ts": 173, - "src/renderer/src/lib/simulator-palette-search.test.ts": 101, - "src/renderer/src/lib/simulator-pane-shutdown-scheduler.test.ts": 1567, - "src/renderer/src/lib/simulator-tab-palette-activation.test.ts": 2039, - "src/renderer/src/lib/skill-freshness-display-status.test.ts": 36, - "src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts": 283, - "src/renderer/src/lib/smart-github-submit.test.ts": 80, - "src/renderer/src/lib/source-control-agent-action-plan.test.ts": 954, - "src/renderer/src/lib/source-control-generation-plan.test.ts": 130, - "src/renderer/src/lib/source-control-launch-agent-selection.test.ts": 180, - "src/renderer/src/lib/source-control-launch-platform.test.ts": 73, - "src/renderer/src/lib/source-control-remote-error.test.ts": 50, - "src/renderer/src/lib/sparse-preset-draft.test.ts": 84, - "src/renderer/src/lib/ssh-background-startup-delivery.test.ts": 79, - "src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts": 1878, - "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 46, - "src/renderer/src/lib/startup-ui-hydration.test.ts": 152, - "src/renderer/src/lib/state-collection-byte-estimate.test.ts": 84, - "src/renderer/src/lib/structured-agent-launch-no-terminal-fallback.test.ts": 272, - "src/renderer/src/lib/structured-agent-launch-settlement.test.ts": 81, - "src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts": 2166, - "src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts": 948, - "src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts": 378, - "src/renderer/src/lib/structured-agent-session-launch-options.test.ts": 660, - "src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts": 449, - "src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts": 357, - "src/renderer/src/lib/structured-agent-session-launch-reload.test.ts": 576, - "src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts": 499, - "src/renderer/src/lib/structured-agent-session-launch.test.ts": 1259, - "src/renderer/src/lib/structured-agent-session-tab-activation.test.ts": 50, - "src/renderer/src/lib/structured-agent-startup-label-census.test.ts": 222, - "src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 420, - "src/renderer/src/lib/tab-agent-status-index.test.ts": 308, - "src/renderer/src/lib/tab-agent.test.ts": 74, - "src/renderer/src/lib/tab-has-live-pty.test.ts": 48, - "src/renderer/src/lib/tab-number-shortcuts.test.ts": 1493, - "src/renderer/src/lib/terminal-contrast-correction.test.ts": 44, - "src/renderer/src/lib/terminal-file-uri-link.test.ts": 33, - "src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 61, - "src/renderer/src/lib/terminal-links-redos.test.ts": 69, - "src/renderer/src/lib/terminal-links.test.ts": 274, - "src/renderer/src/lib/terminal-pane-title-sanitization.test.ts": 51, - "src/renderer/src/lib/terminal-quick-command-project-scope.test.ts": 76, - "src/renderer/src/lib/terminal-quick-command-score-budget.test.ts": 61, - "src/renderer/src/lib/terminal-quick-command-search.test.ts": 73, - "src/renderer/src/lib/terminal-reveal-identity.test.ts": 41, - "src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx": 1568, - "src/renderer/src/lib/terminal-tab-for-pty-id.test.ts": 31, - "src/renderer/src/lib/terminal-theme.test.ts": 80, - "src/renderer/src/lib/terminal-worktree-route.test.ts": 210, - "src/renderer/src/lib/text-control-paste-ownership.test.ts": 475, - "src/renderer/src/lib/text-control-paste.test.ts": 490, - "src/renderer/src/lib/titlebar-left-chrome.test.ts": 27, - "src/renderer/src/lib/titlebar-worktree-history-controls.test.ts": 50, - "src/renderer/src/lib/tui-agent-startup.test.ts": 103, - "src/renderer/src/lib/typing-latency/diagnostic-lifecycle.test.ts": 562, + "src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 323, + "src/renderer/src/lib/passive-macos-app-data-access.test.ts": 49, + "src/renderer/src/lib/paste-payload-metadata.test.ts": 60, + "src/renderer/src/lib/path-head-elision.test.ts": 56, + "src/renderer/src/lib/path.test.ts": 59, + "src/renderer/src/lib/pending-worktree-creation.test.ts": 40, + "src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts": 1634, + "src/renderer/src/lib/pi-session-resume-wake.test.ts": 1408, + "src/renderer/src/lib/plugin-command-execution.test.ts": 165, + "src/renderer/src/lib/plugin-command-keybindings.test.ts": 121, + "src/renderer/src/lib/pr-bot-author-overrides.test.ts": 302, + "src/renderer/src/lib/pr-comment-action-state.test.ts": 54, + "src/renderer/src/lib/pr-comment-reactions.test.ts": 44, + "src/renderer/src/lib/primary-selection-paste.test.ts": 247, + "src/renderer/src/lib/primary-selection.test.ts": 70, + "src/renderer/src/lib/project-clone-url-prefill.test.ts": 59, + "src/renderer/src/lib/project-host-clone-url.test.ts": 49, + "src/renderer/src/lib/project-host-setup-options.test.ts": 325, + "src/renderer/src/lib/project-host-workspace-target-managed-host.test.ts": 340, + "src/renderer/src/lib/project-host-workspace-target.test.ts": 67, + "src/renderer/src/lib/project-skill-runtime.test.ts": 82, + "src/renderer/src/lib/provisioned-root-create-options.test.ts": 52, + "src/renderer/src/lib/quick-workspace-agent-selection.test.ts": 141, + "src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx": 271, + "src/renderer/src/lib/react-commit-cascade-observer.test.ts": 180, + "src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts": 66, + "src/renderer/src/lib/react-commit-cascade-telemetry.test.ts": 164, + "src/renderer/src/lib/react-error-boundary-reporting.test.ts": 73, + "src/renderer/src/lib/react-grab-dev-gate.test.ts": 54, + "src/renderer/src/lib/react-renderer-root.test.ts": 54, + "src/renderer/src/lib/recent-workspace-tab-rows.test.ts": 121, + "src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts": 1646, + "src/renderer/src/lib/rename-user-named-tab-access.test.ts": 1706, + "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 115, + "src/renderer/src/lib/renderer-app-platform.test.ts": 156, + "src/renderer/src/lib/renderer-memory-profile.test.ts": 75, + "src/renderer/src/lib/repo-display-labels.test.ts": 60, + "src/renderer/src/lib/repo-runtime-owner.test.ts": 90, + "src/renderer/src/lib/repo-search.test.ts": 67, + "src/renderer/src/lib/repo-slug-cache.test.ts": 56, + "src/renderer/src/lib/repo-slug-index.test.ts": 2468, + "src/renderer/src/lib/resolve-owner.test.ts": 320, + "src/renderer/src/lib/resolved-worktree-execution-host.test.ts": 282, + "src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts": 1548, + "src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts": 1708, + "src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 1682, + "src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 1680, + "src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 1876, + "src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 1521, + "src/renderer/src/lib/resume-sleeping-agent-session.test.ts": 1401, + "src/renderer/src/lib/resume-stale-structured-agent-session.test.ts": 1623, + "src/renderer/src/lib/reveal-in-file-manager.test.ts": 220, + "src/renderer/src/lib/right-sidebar-visibility.test.ts": 61, + "src/renderer/src/lib/run-quick-command-in-new-tab.test.ts": 72, + "src/renderer/src/lib/running-agent-targets.test.ts": 341, + "src/renderer/src/lib/runtime-host-unverifiable-probe-readers.test.ts": 396, + "src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts": 47, + "src/renderer/src/lib/runtime-session-mirror-targets.test.ts": 63, + "src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts": 53, + "src/renderer/src/lib/runtime-workspace-file-route.test.ts": 319, + "src/renderer/src/lib/screen-submit-shortcut.test.ts": 62, + "src/renderer/src/lib/script-textarea-rows.test.ts": 106, + "src/renderer/src/lib/scroll-cache.test.ts": 56, + "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts": 1625, + "src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts": 1407, + "src/renderer/src/lib/session-write-subscriber-allocation.test.ts": 427, + "src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts": 1534, + "src/renderer/src/lib/session-write-subscriber.test.ts": 1444, + "src/renderer/src/lib/settled-worker-wake-policy.test.ts": 1691, + "src/renderer/src/lib/setup-runner.test.ts": 57, + "src/renderer/src/lib/setup-script-prompt.test.ts": 289, + "src/renderer/src/lib/setup-split-activation-single-owner.test.ts": 1879, + "src/renderer/src/lib/sha256.test.ts": 55, + "src/renderer/src/lib/shutdown-checkpoint-guard.test.ts": 201, + "src/renderer/src/lib/sidebar-worktree-activation.test.ts": 87, + "src/renderer/src/lib/simulator-launch-coordination.test.ts": 114, + "src/renderer/src/lib/simulator-palette-search.test.ts": 335, + "src/renderer/src/lib/simulator-pane-shutdown-scheduler.test.ts": 1417, + "src/renderer/src/lib/simulator-tab-palette-activation.test.ts": 1343, + "src/renderer/src/lib/skill-freshness-display-status.test.ts": 60, + "src/renderer/src/lib/sleeping-agent-pane-ownership.test.ts": 53, + "src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts": 524, + "src/renderer/src/lib/smart-github-submit.test.ts": 104, + "src/renderer/src/lib/solidity-language-id-registration.test.ts": 48, + "src/renderer/src/lib/source-control-agent-action-launch-check.test.ts": 1358, + "src/renderer/src/lib/source-control-generation-plan.test.ts": 114, + "src/renderer/src/lib/source-control-launch-platform.test.ts": 58, + "src/renderer/src/lib/source-control-remote-error.test.ts": 62, + "src/renderer/src/lib/sparse-preset-draft.test.ts": 168, + "src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts": 1527, + "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 1556, + "src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts": 47, + "src/renderer/src/lib/startup-draft-input-kind.test.ts": 464, + "src/renderer/src/lib/startup-ui-hydration.test.ts": 270, + "src/renderer/src/lib/state-collection-byte-estimate.test.ts": 113, + "src/renderer/src/lib/structured-agent-launch-settlement.test.ts": 64, + "src/renderer/src/lib/structured-agent-session-launch-admission.test.ts": 3534, + "src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts": 585, + "src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts": 1639, + "src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts": 663, + "src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts": 1863, + "src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts": 607, + "src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts": 944, + "src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts": 508, + "src/renderer/src/lib/structured-agent-session-launch-options.test.ts": 602, + "src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts": 374, + "src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts": 1677, + "src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts": 511, + "src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts": 481, + "src/renderer/src/lib/structured-agent-session-launch-record.test.ts": 203, + "src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts": 610, + "src/renderer/src/lib/structured-agent-session-launch-reload.test.ts": 573, + "src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts": 602, + "src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts": 539, + "src/renderer/src/lib/structured-agent-session-launch.test.ts": 698, + "src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts": 656, + "src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts": 1115, + "src/renderer/src/lib/structured-agent-session-tab-activation.test.ts": 59, + "src/renderer/src/lib/structured-chat-tab-index.test.ts": 42, + "src/renderer/src/lib/syntax-highlighting/incremental-syntax-tokenizer.test.ts": 553, + "src/renderer/src/lib/syntax-highlighting/syntax-highlight-scheduler.test.ts": 151, + "src/renderer/src/lib/syntax-highlighting/syntax-highlighter.test.ts": 476, + "src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 542, + "src/renderer/src/lib/tab-agent-status-index.test.ts": 224, + "src/renderer/src/lib/tab-agent.test.ts": 127, + "src/renderer/src/lib/tab-has-live-pty.test.ts": 62, + "src/renderer/src/lib/tab-number-shortcuts.test.ts": 1566, + "src/renderer/src/lib/task-source-runtime-target.test.ts": 47, + "src/renderer/src/lib/terminal-contrast-correction.test.ts": 64, + "src/renderer/src/lib/terminal-file-uri-link.test.ts": 74, + "src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 58, + "src/renderer/src/lib/terminal-links-cjk.test.ts": 72, + "src/renderer/src/lib/terminal-links-redos.test.ts": 74, + "src/renderer/src/lib/terminal-links.test.ts": 282, + "src/renderer/src/lib/terminal-pane-title-sanitization.test.ts": 49, + "src/renderer/src/lib/terminal-path-normalization.test.ts": 50, + "src/renderer/src/lib/terminal-quick-command-project-scope.test.ts": 58, + "src/renderer/src/lib/terminal-quick-command-score-budget.test.ts": 57, + "src/renderer/src/lib/terminal-quick-command-search.test.ts": 70, + "src/renderer/src/lib/terminal-reveal-identity.test.ts": 42, + "src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx": 1656, + "src/renderer/src/lib/terminal-tab-for-pty-id.test.ts": 63, + "src/renderer/src/lib/terminal-tab-owner-activation.test.ts": 50, + "src/renderer/src/lib/terminal-theme.test.ts": 84, + "src/renderer/src/lib/terminal-worktree-route.test.ts": 312, + "src/renderer/src/lib/text-control-paste-ownership.test.ts": 190, + "src/renderer/src/lib/text-control-paste.test.ts": 166, + "src/renderer/src/lib/titlebar-left-chrome.test.ts": 51, + "src/renderer/src/lib/titlebar-worktree-history-controls.test.ts": 54, + "src/renderer/src/lib/tui-agent-startup.test.ts": 155, + "src/renderer/src/lib/typing-latency/diagnostic-lifecycle.test.ts": 183, "src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts": 61, - "src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 246, - "src/renderer/src/lib/typing-latency/input-events.test.ts": 385, - "src/renderer/src/lib/typing-latency/input-source.test.ts": 75, - "src/renderer/src/lib/typing-latency/sample-window.test.ts": 57, - "src/renderer/src/lib/ui-zoom.test.ts": 59, - "src/renderer/src/lib/unread-badge-count.test.ts": 51, - "src/renderer/src/lib/update-check-click-options.test.ts": 40, - "src/renderer/src/lib/updater-beforeunload.test.ts": 60, - "src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 1567, - "src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 1480, - "src/renderer/src/lib/use-tab-agent-pi-identity.test.ts": 92, - "src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 2086, - "src/renderer/src/lib/use-tab-agent-remote-pty-selector.test.ts": 118, - "src/renderer/src/lib/use-tab-agent-retained-identity.test.ts": 1549, - "src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts": 1948, - "src/renderer/src/lib/use-tab-agent.test.ts": 1893, - "src/renderer/src/lib/visible-overlay.test.ts": 464, - "src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 276, - "src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts": 437, - "src/renderer/src/lib/web-client-location.test.ts": 52, - "src/renderer/src/lib/window-label-formatter.test.ts": 27, - "src/renderer/src/lib/window-visibility-interval.test.ts": 70, - "src/renderer/src/lib/window-visibility-timeout-poller.test.ts": 67, - "src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 317, - "src/renderer/src/lib/windows-terminal-capabilities.test.ts": 401, - "src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 432, - "src/renderer/src/lib/work-item-link-query-bounds.test.ts": 49, - "src/renderer/src/lib/work-item-lookup-text.test.ts": 55, - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts": 314, - "src/renderer/src/lib/workspace-activation-path-gate.test.ts": 1954, - "src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 345, - "src/renderer/src/lib/workspace-browser-tab-open.test.ts": 325, - "src/renderer/src/lib/workspace-composer-initial-focus.test.ts": 351, - "src/renderer/src/lib/workspace-create-error-format.test.ts": 99, - "src/renderer/src/lib/workspace-doc-address-input.test.ts": 208, - "src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 103, - "src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 79, - "src/renderer/src/lib/workspace-file-drag.test.ts": 94, - "src/renderer/src/lib/workspace-port-groups.test.ts": 49, - "src/renderer/src/lib/workspace-port-host-availability.test.ts": 40, - "src/renderer/src/lib/workspace-port-scan-debounce.test.ts": 48, - "src/renderer/src/lib/workspace-port-scan-publish.test.ts": 672, - "src/renderer/src/lib/workspace-session-browser-history.test.ts": 196, - "src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts": 146, - "src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 156, - "src/renderer/src/lib/workspace-session-host-contention.test.ts": 236, - "src/renderer/src/lib/workspace-session-host-persistence.test.ts": 301, - "src/renderer/src/lib/workspace-session-host-split.test.ts": 128, - "src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 36, - "src/renderer/src/lib/workspace-session-liveness.test.ts": 133, - "src/renderer/src/lib/workspace-session-patch.test.ts": 196, - "src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 209, - "src/renderer/src/lib/workspace-session-relevant-fields.test.ts": 200, - "src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts": 210, - "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts": 235, - "src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 39, - "src/renderer/src/lib/workspace-session.test.ts": 150, - "src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 49, - "src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 1538, - "src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 73, - "src/renderer/src/lib/workspace-tab-palette-results.test.ts": 105, - "src/renderer/src/lib/workspace-tab-palette-search.test.ts": 216, - "src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 1735, - "src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 1648, - "src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 1897, - "src/renderer/src/lib/worktree-activation-created-agent.test.ts": 1592, - "src/renderer/src/lib/worktree-activation-default-tabs.test.ts": 1319, - "src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 2006, - "src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 1783, - "src/renderer/src/lib/worktree-activation-issue-command.test.ts": 1036, - "src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 182, - "src/renderer/src/lib/worktree-activation-reveal.test.ts": 1804, - "src/renderer/src/lib/worktree-activation-setup-script.test.ts": 1979, - "src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 1880, - "src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts": 290, - "src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 1340, - "src/renderer/src/lib/worktree-activity-state.test.ts": 69, - "src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 1546, - "src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 1685, - "src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 132, - "src/renderer/src/lib/worktree-attachment-label.test.ts": 56, - "src/renderer/src/lib/worktree-creation-agent-seeding.test.ts": 622, - "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 1725, - "src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 1752, - "src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts": 34, - "src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts": 887, - "src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 1050, - "src/renderer/src/lib/worktree-creation-flow.test.ts": 2555, - "src/renderer/src/lib/worktree-creation-structured-session.test.ts": 922, - "src/renderer/src/lib/worktree-creation-surface.test.ts": 43, - "src/renderer/src/lib/worktree-default-display-name.test.ts": 67, - "src/renderer/src/lib/worktree-display-name-order.test.ts": 33, - "src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts": 1565, - "src/renderer/src/lib/worktree-git-identity-display.test.ts": 78, - "src/renderer/src/lib/worktree-jump-navigation.test.ts": 231, - "src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 210, - "src/renderer/src/lib/worktree-operation-generation.test.ts": 319, - "src/renderer/src/lib/worktree-operation-route.test.ts": 63, - "src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 56, - "src/renderer/src/lib/worktree-palette-create-action.test.ts": 52, - "src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 216, - "src/renderer/src/lib/worktree-palette-review-match.test.ts": 96, - "src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 81, - "src/renderer/src/lib/worktree-palette-search.test.ts": 166, - "src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 118, - "src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 2149, - "src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 1803, - "src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 1778, - "src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 956, - "src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 184, - "src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 37, - "src/renderer/src/lib/worktree-runtime-owner.test.ts": 233, - "src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 150, - "src/renderer/src/lib/worktree-sort-order-persistence.test.ts": 179, - "src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 59, - "src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 58, - "src/renderer/src/lib/worktree-status.interrupted.test.ts": 74, - "src/renderer/src/lib/worktree-status.test.ts": 98, - "src/renderer/src/lib/worktree-visit-recency.test.ts": 37, - "src/renderer/src/renderer-node-builtin-boundary.test.ts": 1218, - "src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts": 39, - "src/renderer/src/runtime/agent-session-keyboard-capability.test.ts": 66, - "src/renderer/src/runtime/agent-session-operation-id.test.ts": 64, - "src/renderer/src/runtime/antigravity-generation-compatibility.test.ts": 167, - "src/renderer/src/runtime/browser-client-host-identity.test.ts": 60, - "src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts": 361, - "src/renderer/src/runtime/browser-workspace-tab-close-plan.test.ts": 33, - "src/renderer/src/runtime/browser-workspace-tab-close.test.ts": 92, - "src/renderer/src/runtime/client-hosted-browser-close-intent-replay.test.ts": 172, - "src/renderer/src/runtime/client-hosted-browser-close-intents.test.ts": 103, - "src/renderer/src/runtime/client-hosted-browser-row-close.test.ts": 216, - "src/renderer/src/runtime/close-mirrored-editor-tab.test.ts": 107, - "src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts": 2077, - "src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts": 502, - "src/renderer/src/runtime/github-check-details-timeout.test.ts": 41, - "src/renderer/src/runtime/gitlab-job-trace-client.test.ts": 141, - "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts": 630, - "src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts": 197, - "src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts": 239, - "src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx": 1759, - "src/renderer/src/runtime/host-session-mirror-settle-census.test.ts": 505, - "src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx": 1864, - "src/renderer/src/runtime/host-session-snapshot-authority-client-hosted.test.ts": 52, - "src/renderer/src/runtime/local-runtime-capabilities.test.ts": 473, - "src/renderer/src/runtime/local-session-tab-close-owner.test.ts": 58, - "src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts": 1132, - "src/renderer/src/runtime/local-structured-session-retired-epoch-repair.test.ts": 1743, - "src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts": 1580, - "src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts": 779, - "src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts": 2524, - "src/renderer/src/runtime/local-structured-session-tabs-sync/retired-epoch-repair.test.ts": 67, - "src/renderer/src/runtime/mirrored-agent-status-clock-skew.test.ts": 1299, - "src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts": 1242, - "src/renderer/src/runtime/mobile-markdown-bridge.test.ts": 1361, - "src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts": 38, - "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts": 1002, - "src/renderer/src/runtime/remote-agent-row-last-assistant-message.test.ts": 1196, - "src/renderer/src/runtime/remote-agent-session-launch.test.ts": 62, - "src/renderer/src/runtime/remote-host-file-delete-repro.test.ts": 2161, - "src/renderer/src/runtime/remote-host-file-open-repro.test.ts": 1628, - "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts": 50, - "src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts": 118, - "src/renderer/src/runtime/remote-runtime-terminal-end-verdict.test.ts": 86, - "src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts": 109, - "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts": 334, - "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts": 96, - "src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts": 87, - "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts": 263, - "src/renderer/src/runtime/remote-server-install-failure-probe.test.ts": 61, - "src/renderer/src/runtime/remote-server-parity.test.ts": 1760, - "src/renderer/src/runtime/remote-server-restart-wait.test.ts": 43, - "src/renderer/src/runtime/remote-server-update-coordinator.test.ts": 95, - "src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts": 61, - "src/renderer/src/runtime/remote-terminal-stream-watchdog.test.ts": 52, - "src/renderer/src/runtime/restored-client-hosted-browser-host-attach.test.ts": 67, - "src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts": 286, - "src/renderer/src/runtime/runtime-client-events.test.ts": 55, - "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 1134, - "src/renderer/src/runtime/runtime-file-client-download.test.ts": 218, - "src/renderer/src/runtime/runtime-file-client-external-import.test.ts": 324, - "src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 208, - "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 276, - "src/renderer/src/runtime/runtime-file-client-watch.test.ts": 388, - "src/renderer/src/runtime/runtime-file-client.test.ts": 367, - "src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts": 311, - "src/renderer/src/runtime/runtime-file-search-bounds.test.ts": 38, - "src/renderer/src/runtime/runtime-git-client-api-contract.test.ts": 156, - "src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts": 182, - "src/renderer/src/runtime/runtime-git-client-merge.test.ts": 137, - "src/renderer/src/runtime/runtime-git-client.test.ts": 255, - "src/renderer/src/runtime/runtime-hooks-client.test.ts": 73, - "src/renderer/src/runtime/runtime-host-connection-state.test.ts": 50, - "src/renderer/src/runtime/runtime-host-contact-parity.test.ts": 81, - "src/renderer/src/runtime/runtime-jira-client.test.ts": 555, - "src/renderer/src/runtime/runtime-jira-payload-stream.test.ts": 242, - "src/renderer/src/runtime/runtime-linear-client.test.ts": 89, - "src/renderer/src/runtime/runtime-path-existence-batch.test.ts": 249, - "src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts": 192, - "src/renderer/src/runtime/runtime-path-existence-pairing.test.ts": 236, - "src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts": 252, - "src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 107, - "src/renderer/src/runtime/runtime-provider-search-bounds.test.ts": 44, - "src/renderer/src/runtime/runtime-repo-client.test.ts": 219, - "src/renderer/src/runtime/runtime-repo-search-bounds.test.ts": 38, - "src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts": 50, - "src/renderer/src/runtime/runtime-rpc-client.test.ts": 102, - "src/renderer/src/runtime/runtime-rpc-result.test.ts": 28, - "src/renderer/src/runtime/runtime-server-directory-browser.test.ts": 72, - "src/renderer/src/runtime/runtime-skills-client.test.ts": 78, - "src/renderer/src/runtime/runtime-skills-delete-client.test.ts": 61, - "src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 1867, - "src/renderer/src/runtime/runtime-terminal-stream.test.ts": 341, - "src/renderer/src/runtime/runtime-worktree-selector.test.ts": 123, - "src/renderer/src/runtime/structured-agent-session-client.test.ts": 324, - "src/renderer/src/runtime/structured-agent-session-close.test.ts": 59, - "src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts": 37, - "src/renderer/src/runtime/structured-agent-session-status-feed.test.ts": 83, - "src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts": 55, - "src/renderer/src/runtime/structured-agent-session-turn-completion-feed.test.ts": 89, - "src/renderer/src/runtime/structured-conversation-tab-replacement.test.ts": 1641, - "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection-join.test.ts": 422, - "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts": 334, - "src/renderer/src/runtime/sync-runtime-graph-automation-leaf.test.ts": 289, - "src/renderer/src/runtime/sync-runtime-graph-browser.test.ts": 244, - "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts": 1365, - "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts": 332, - "src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts": 358, - "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts": 237, - "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts": 305, - "src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts": 542, - "src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts": 270, - "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts": 264, - "src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts": 1061, - "src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts": 349, - "src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts": 225, - "src/renderer/src/runtime/sync-runtime-graph-workspace-publication.test.ts": 369, - "src/renderer/src/runtime/sync-runtime-graph.test.ts": 342, - "src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts": 82, - "src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts": 555, - "src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts": 2476, - "src/renderer/src/runtime/use-worktree-runtime-target.test.ts": 2118, - "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts": 230, - "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts": 35, - "src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts": 54, - "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts": 508, - "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts": 508, - "src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts": 494, - "src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts": 500, - "src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts": 530, - "src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts": 347, - "src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts": 876, - "src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts": 390, - "src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts": 470, - "src/renderer/src/runtime/web-runtime-session-tab-move.test.ts": 317, - "src/renderer/src/runtime/web-runtime-session-tab-props.test.ts": 512, - "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts": 461, - "src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts": 336, - "src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts": 1290, - "src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts": 366, - "src/renderer/src/runtime/web-runtime-session.test.ts": 384, - "src/renderer/src/runtime/web-runtime-terminal-identity.test.ts": 57, - "src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts": 47, - "src/renderer/src/runtime/web-session-browser-placement.test.ts": 48, - "src/renderer/src/runtime/web-session-close-intent.test.ts": 53, - "src/renderer/src/runtime/web-session-existing-tab-index.test.ts": 35, - "src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts": 1811, - "src/renderer/src/runtime/web-session-intent-owner.test.ts": 41, - "src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts": 1150, - "src/renderer/src/runtime/web-session-structured-tab-focus.test.ts": 1370, - "src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts": 1511, - "src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts": 699, - "src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts": 472, - "src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts": 648, - "src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 643, - "src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 732, - "src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 699, - "src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 617, - "src/renderer/src/runtime/web-session-tabs-sync-client-owned-placement.test.ts": 727, - "src/renderer/src/runtime/web-session-tabs-sync-diff-focus-steal.test.ts": 468, - "src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts": 668, - "src/renderer/src/runtime/web-session-tabs-sync-emptied-workspace-tombstone.test.ts": 817, - "src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts": 603, - "src/renderer/src/runtime/web-session-tabs-sync-host-restart-browser-rows.test.ts": 722, - "src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts": 2099, - "src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts": 659, - "src/renderer/src/runtime/web-session-tabs-sync-initial-terminal-relaunch.test.tsx": 2154, - "src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts": 763, - "src/renderer/src/runtime/web-session-tabs-sync-layout-groups.test.ts": 671, - "src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 1309, - "src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts": 2175, - "src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx": 2266, - "src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 1052, - "src/renderer/src/runtime/web-session-tabs-sync-restored-browser-rows.test.ts": 735, - "src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 666, - "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-adoption.test.ts": 789, - "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-authority.test.ts": 732, - "src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts": 603, - "src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts": 756, - "src/renderer/src/runtime/web-session-tabs-sync-tracking-teardown.test.ts": 700, - "src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx": 1481, - "src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx": 2322, - "src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx": 6067, - "src/renderer/src/runtime/web-session-tabs-sync.test.ts": 610, - "src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts": 1260, - "src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.test.ts": 1840, - "src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts": 1252, - "src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts": 1429, - "src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts": 1590, - "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts": 29, - "src/renderer/src/runtime/web-session-terminal-orphan-absence-across-republication.test.ts": 313, - "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts": 1303, - "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts": 607, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts": 754, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts": 306, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-prior-removal.test.ts": 328, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts": 607, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts": 370, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts": 327, - "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts": 229, - "src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.test.ts": 444, - "src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts": 350, - "src/renderer/src/runtime/window-visibility-subscription-parking.test.ts": 524, + "src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 145, + "src/renderer/src/lib/typing-latency/input-events.test.ts": 205, + "src/renderer/src/lib/typing-latency/input-source.test.ts": 77, + "src/renderer/src/lib/typing-latency/sample-window.test.ts": 53, + "src/renderer/src/lib/ui-zoom.test.ts": 73, + "src/renderer/src/lib/unified-tab-anchor-insertion.test.ts": 48, + "src/renderer/src/lib/unread-badge-count-migration.test.ts": 306, + "src/renderer/src/lib/unread-badge-count-selector.test.ts": 1368, + "src/renderer/src/lib/unread-badge-count.test.ts": 190, + "src/renderer/src/lib/update-check-click-options.test.ts": 49, + "src/renderer/src/lib/updater-beforeunload.test.ts": 58, + "src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 1424, + "src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 1734, + "src/renderer/src/lib/use-tab-agent-pi-identity.test.ts": 125, + "src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 1363, + "src/renderer/src/lib/use-tab-agent-remote-pty-selector.test.ts": 159, + "src/renderer/src/lib/use-tab-agent-retained-identity.test.ts": 1903, + "src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts": 1563, + "src/renderer/src/lib/use-tab-agent.test.ts": 1826, + "src/renderer/src/lib/user-opened-local-path.test.ts": 1464, + "src/renderer/src/lib/visible-overlay.test.ts": 165, + "src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 203, + "src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts": 161, + "src/renderer/src/lib/web-client-location.test.ts": 51, + "src/renderer/src/lib/window-label-formatter.test.ts": 44, + "src/renderer/src/lib/window-visibility-interval.test.ts": 66, + "src/renderer/src/lib/window-visibility-timeout-poller.test.ts": 173, + "src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 220, + "src/renderer/src/lib/windows-terminal-capabilities.test.ts": 282, + "src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 190, + "src/renderer/src/lib/work-item-link-query-bounds.test.ts": 55, + "src/renderer/src/lib/work-item-lookup-text.test.ts": 57, + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts": 299, + "src/renderer/src/lib/workspace-activation-path-gate.test.ts": 1672, + "src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 340, + "src/renderer/src/lib/workspace-attachment-terminal-origin.test.ts": 88, + "src/renderer/src/lib/workspace-browser-tab-open.test.ts": 398, + "src/renderer/src/lib/workspace-composer-initial-focus.test.ts": 148, + "src/renderer/src/lib/workspace-create-error-format.test.ts": 82, + "src/renderer/src/lib/workspace-directory.test.ts": 359, + "src/renderer/src/lib/workspace-doc-address-input.test.ts": 283, + "src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 95, + "src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 95, + "src/renderer/src/lib/workspace-file-drag-source-runtime.test.ts": 62, + "src/renderer/src/lib/workspace-file-drag.test.ts": 80, + "src/renderer/src/lib/workspace-port-groups.test.ts": 44, + "src/renderer/src/lib/workspace-port-host-availability.test.ts": 63, + "src/renderer/src/lib/workspace-port-host-scoped-scan.test.ts": 337, + "src/renderer/src/lib/workspace-port-scan-debounce.test.ts": 51, + "src/renderer/src/lib/workspace-port-scan-publish.test.ts": 445, + "src/renderer/src/lib/workspace-session-browser-history.test.ts": 331, + "src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 312, + "src/renderer/src/lib/workspace-session-host-contention.test.ts": 357, + "src/renderer/src/lib/workspace-session-host-persistence.test.ts": 333, + "src/renderer/src/lib/workspace-session-host-split.test.ts": 316, + "src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 58, + "src/renderer/src/lib/workspace-session-liveness.test.ts": 284, + "src/renderer/src/lib/workspace-session-patch.test.ts": 292, + "src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 287, + "src/renderer/src/lib/workspace-session-relevant-fields.test.ts": 325, + "src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts": 338, + "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts": 347, + "src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 48, + "src/renderer/src/lib/workspace-session.test.ts": 318, + "src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 56, + "src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 1688, + "src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 294, + "src/renderer/src/lib/workspace-tab-palette-results.test.ts": 350, + "src/renderer/src/lib/workspace-tab-palette-search.test.ts": 423, + "src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 1753, + "src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 2012, + "src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 1819, + "src/renderer/src/lib/worktree-activation-created-agent.test.ts": 2057, + "src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts": 1659, + "src/renderer/src/lib/worktree-activation-default-tabs.test.ts": 1571, + "src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 1934, + "src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 1634, + "src/renderer/src/lib/worktree-activation-issue-command.test.ts": 1693, + "src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts": 1657, + "src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 321, + "src/renderer/src/lib/worktree-activation-reveal.test.ts": 1759, + "src/renderer/src/lib/worktree-activation-setup-script.test.ts": 1905, + "src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 1865, + "src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 2050, + "src/renderer/src/lib/worktree-activity-state.test.ts": 66, + "src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 1647, + "src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 1849, + "src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 282, + "src/renderer/src/lib/worktree-attachment-label.test.ts": 57, + "src/renderer/src/lib/worktree-creation-agent-seeding.test.ts": 1201, + "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 1452, + "src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts": 1881, + "src/renderer/src/lib/worktree-creation-background-mount.test.ts": 2059, + "src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 2093, + "src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts": 1253, + "src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts": 1699, + "src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 1183, + "src/renderer/src/lib/worktree-creation-flow.test.ts": 1987, + "src/renderer/src/lib/worktree-creation-hook-flow.test.ts": 1338, + "src/renderer/src/lib/worktree-creation-hook-preparation.test.ts": 185, + "src/renderer/src/lib/worktree-creation-ready-toast.test.tsx": 173, + "src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts": 1994, + "src/renderer/src/lib/worktree-creation-structured-session.test.ts": 1041, + "src/renderer/src/lib/worktree-creation-surface.test.ts": 54, + "src/renderer/src/lib/worktree-default-display-name.test.ts": 52, + "src/renderer/src/lib/worktree-display-name-order.test.ts": 47, + "src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts": 1335, + "src/renderer/src/lib/worktree-git-identity-display.test.ts": 46, + "src/renderer/src/lib/worktree-host-connection-phase.test.ts": 1513, + "src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts": 1617, + "src/renderer/src/lib/worktree-jump-navigation.test.ts": 354, + "src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 314, + "src/renderer/src/lib/worktree-operation-generation.test.ts": 379, + "src/renderer/src/lib/worktree-operation-route.test.ts": 356, + "src/renderer/src/lib/worktree-palette-attachment-urls.test.ts": 109, + "src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 50, + "src/renderer/src/lib/worktree-palette-create-action.test.ts": 63, + "src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 235, + "src/renderer/src/lib/worktree-palette-review-match.test.ts": 194, + "src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 402, + "src/renderer/src/lib/worktree-palette-search.test.ts": 236, + "src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 133, + "src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 1546, + "src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 1410, + "src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 1937, + "src/renderer/src/lib/worktree-row-open-in-owner.test.ts": 364, + "src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 298, + "src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 102, + "src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 49, + "src/renderer/src/lib/worktree-runtime-owner.test.ts": 356, + "src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 320, + "src/renderer/src/lib/worktree-sort-order-persistence.test.ts": 342, + "src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 127, + "src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 115, + "src/renderer/src/lib/worktree-status.interrupted.test.ts": 106, + "src/renderer/src/lib/worktree-status.test.ts": 121, + "src/renderer/src/lib/worktree-visit-recency.test.ts": 45, + "src/renderer/src/runtime/abortable-runtime-environment-call.test.ts": 181, + "src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts": 70, + "src/renderer/src/runtime/agent-session-keyboard-capability.test.ts": 96, + "src/renderer/src/runtime/agent-session-operation-id.test.ts": 87, + "src/renderer/src/runtime/antigravity-generation-compatibility.test.ts": 349, + "src/renderer/src/runtime/browser-client-host-identity.test.ts": 52, + "src/renderer/src/runtime/browser-workspace-tab-close-plan.test.ts": 55, + "src/renderer/src/runtime/browser-workspace-tab-close.test.ts": 238, + "src/renderer/src/runtime/client-hosted-browser-close-intent-replay.test.ts": 374, + "src/renderer/src/runtime/client-hosted-browser-close-intents.test.ts": 234, + "src/renderer/src/runtime/client-hosted-browser-row-close.test.ts": 264, + "src/renderer/src/runtime/close-mirrored-editor-tab.test.ts": 224, + "src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts": 1862, + "src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts": 570, + "src/renderer/src/runtime/focus-runtime-terminal-surface-readiness.test.ts": 437, + "src/renderer/src/runtime/github-check-details-timeout.test.ts": 64, + "src/renderer/src/runtime/gitlab-job-trace-client.test.ts": 137, + "src/renderer/src/runtime/gitlab-owner-api.test.ts": 111, + "src/renderer/src/runtime/host-model-catalog-snapshots.test.ts": 1805, + "src/renderer/src/runtime/host-route-call.test.ts": 338, + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts": 944, + "src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts": 384, + "src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts": 343, + "src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx": 2135, + "src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx": 2168, + "src/renderer/src/runtime/host-session-snapshot-authority-client-hosted.test.ts": 54, + "src/renderer/src/runtime/host-structured-agents.test.ts": 537, + "src/renderer/src/runtime/local-runtime-capabilities.test.ts": 139, + "src/renderer/src/runtime/local-session-tab-close-owner.test.ts": 44, + "src/renderer/src/runtime/local-structured-chats.test.ts": 69, + "src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts": 1662, + "src/renderer/src/runtime/local-structured-session-retired-epoch-repair.test.ts": 1933, + "src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts": 2162, + "src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts": 1048, + "src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts": 1933, + "src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts": 2619, + "src/renderer/src/runtime/local-structured-session-tabs-sync/retired-epoch-repair.test.ts": 63, + "src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts": 1704, + "src/renderer/src/runtime/mirrored-agent-status-clock-skew.test.ts": 1534, + "src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts": 1255, + "src/renderer/src/runtime/mobile-markdown-bridge.test.ts": 2156, + "src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts": 53, + "src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts": 1736, + "src/renderer/src/runtime/paired-host-client-capabilities.test.ts": 251, + "src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts": 487, + "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts": 1745, + "src/renderer/src/runtime/remote-agent-row-last-assistant-message.test.ts": 1747, + "src/renderer/src/runtime/remote-agent-session-launch.test.ts": 101, + "src/renderer/src/runtime/remote-host-file-delete-repro.test.ts": 1622, + "src/renderer/src/runtime/remote-host-file-open-repro.test.ts": 1387, + "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts": 64, + "src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts": 150, + "src/renderer/src/runtime/remote-runtime-terminal-color-push.test.ts": 89, + "src/renderer/src/runtime/remote-runtime-terminal-end-verdict.test.ts": 156, + "src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts": 152, + "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts": 442, + "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts": 141, + "src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts": 115, + "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts": 273, + "src/renderer/src/runtime/remote-server-install-failure-probe.test.ts": 49, + "src/renderer/src/runtime/remote-server-parity.test.ts": 1540, + "src/renderer/src/runtime/remote-server-restart-wait.test.ts": 64, + "src/renderer/src/runtime/remote-server-update-coordinator.test.ts": 112, + "src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts": 55, + "src/renderer/src/runtime/remote-terminal-stream-watchdog.test.ts": 54, + "src/renderer/src/runtime/restored-client-hosted-browser-host-attach.test.ts": 59, + "src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts": 301, + "src/renderer/src/runtime/runtime-antigravity-accounts-client.test.ts": 69, + "src/renderer/src/runtime/runtime-client-events.test.ts": 61, + "src/renderer/src/runtime/runtime-client-target.test.ts": 46, + "src/renderer/src/runtime/runtime-environment-pairing-refresh.test.ts": 98, + "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 1589, + "src/renderer/src/runtime/runtime-file-client-download.test.ts": 397, + "src/renderer/src/runtime/runtime-file-client-external-import.test.ts": 419, + "src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 406, + "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 606, + "src/renderer/src/runtime/runtime-file-client-watch.test.ts": 508, + "src/renderer/src/runtime/runtime-file-client.test.ts": 397, + "src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts": 394, + "src/renderer/src/runtime/runtime-file-range-client.test.ts": 338, + "src/renderer/src/runtime/runtime-file-search-bounds.test.ts": 49, + "src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts": 308, + "src/renderer/src/runtime/runtime-git-client-merge.test.ts": 338, + "src/renderer/src/runtime/runtime-git-client.test.ts": 421, + "src/renderer/src/runtime/runtime-git-worktree-scope-client.test.ts": 439, + "src/renderer/src/runtime/runtime-hooks-client.test.ts": 101, + "src/renderer/src/runtime/runtime-host-connection-state.test.ts": 57, + "src/renderer/src/runtime/runtime-host-contact-parity.test.ts": 58, + "src/renderer/src/runtime/runtime-host-contact-regained.test.ts": 342, + "src/renderer/src/runtime/runtime-jira-client.test.ts": 230, + "src/renderer/src/runtime/runtime-jira-connection-status.test.ts": 50, + "src/renderer/src/runtime/runtime-jira-payload-stream.test.ts": 198, + "src/renderer/src/runtime/runtime-layout-client.test.ts": 115, + "src/renderer/src/runtime/runtime-legacy-inventory-budget.test.ts": 58, + "src/renderer/src/runtime/runtime-legacy-quick-open-inventory-expiry.test.ts": 733, + "src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts": 343, + "src/renderer/src/runtime/runtime-linear-client.test.ts": 118, + "src/renderer/src/runtime/runtime-path-existence-batch.test.ts": 327, + "src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts": 339, + "src/renderer/src/runtime/runtime-path-existence-pairing.test.ts": 307, + "src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts": 320, + "src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 132, + "src/renderer/src/runtime/runtime-provider-search-bounds.test.ts": 50, + "src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts": 484, + "src/renderer/src/runtime/runtime-repo-client.test.ts": 183, + "src/renderer/src/runtime/runtime-repo-search-bounds.test.ts": 53, + "src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts": 89, + "src/renderer/src/runtime/runtime-rpc-client.test.ts": 128, + "src/renderer/src/runtime/runtime-rpc-result.test.ts": 46, + "src/renderer/src/runtime/runtime-server-directory-browser.test.ts": 86, + "src/renderer/src/runtime/runtime-skills-client.test.ts": 90, + "src/renderer/src/runtime/runtime-skills-delete-client.test.ts": 74, + "src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 1879, + "src/renderer/src/runtime/runtime-terminal-stream.test.ts": 408, + "src/renderer/src/runtime/runtime-terminal-verified-input.test.ts": 1396, + "src/renderer/src/runtime/runtime-worktree-selector.test.ts": 310, + "src/renderer/src/runtime/structured-agent-session-client.test.ts": 184, + "src/renderer/src/runtime/structured-agent-session-close.test.ts": 92, + "src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx": 371, + "src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts": 95, + "src/renderer/src/runtime/structured-agent-session-status-feed.test.ts": 104, + "src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts": 1480, + "src/renderer/src/runtime/structured-agent-session-turn-completion-feed.test.ts": 102, + "src/renderer/src/runtime/structured-conversation-tab-replacement.test.ts": 1794, + "src/renderer/src/runtime/structured-session-orca-session-id.test.ts": 271, + "src/renderer/src/runtime/structured-session-tab-inventory.test.ts": 57, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection-join.test.ts": 448, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts": 488, + "src/renderer/src/runtime/sync-runtime-graph-automation-leaf.test.ts": 471, + "src/renderer/src/runtime/sync-runtime-graph-browser.test.ts": 411, + "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts": 1416, + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts": 425, + "src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts": 504, + "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts": 445, + "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts": 443, + "src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts": 513, + "src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts": 538, + "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts": 396, + "src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts": 877, + "src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts": 429, + "src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts": 513, + "src/renderer/src/runtime/sync-runtime-graph-workspace-publication.test.ts": 423, + "src/renderer/src/runtime/sync-runtime-graph.test.ts": 483, + "src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts": 74, + "src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts": 277, + "src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts": 1954, + "src/renderer/src/runtime/use-worktree-runtime-target.test.ts": 1773, + "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts": 603, + "src/renderer/src/runtime/web-runtime-browser-creation-placement.test.ts": 1772, + "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts": 44, + "src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts": 57, + "src/renderer/src/runtime/web-runtime-pane-layout-intent.test.ts": 561, + "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts": 681, + "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts": 610, + "src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts": 871, + "src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts": 969, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts": 643, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts": 480, + "src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts": 1143, + "src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts": 606, + "src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts": 647, + "src/renderer/src/runtime/web-runtime-session-tab-move.test.ts": 580, + "src/renderer/src/runtime/web-runtime-session-tab-props.test.ts": 667, + "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts": 636, + "src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts": 597, + "src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts": 1145, + "src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts": 612, + "src/renderer/src/runtime/web-runtime-session.test.ts": 512, + "src/renderer/src/runtime/web-runtime-terminal-identity.test.ts": 59, + "src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts": 53, + "src/renderer/src/runtime/web-session-browser-placement.test.ts": 87, + "src/renderer/src/runtime/web-session-close-intent.test.ts": 51, + "src/renderer/src/runtime/web-session-existing-tab-index.test.ts": 41, + "src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts": 1527, + "src/renderer/src/runtime/web-session-intent-owner.test.ts": 64, + "src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts": 1429, + "src/renderer/src/runtime/web-session-restored-placeholder-identity.test.ts": 743, + "src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts": 851, + "src/renderer/src/runtime/web-session-structured-tab-focus.test.ts": 1830, + "src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts": 1651, + "src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts": 620, + "src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts": 566, + "src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts": 524, + "src/renderer/src/runtime/web-session-tabs-retired-epoch-census-repair.test.tsx": 1870, + "src/renderer/src/runtime/web-session-tabs-stream-end-resubscribe.test.tsx": 1954, + "src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 858, + "src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 936, + "src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 924, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 1153, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-placement.test.ts": 926, + "src/renderer/src/runtime/web-session-tabs-sync-diff-focus-steal.test.ts": 880, + "src/renderer/src/runtime/web-session-tabs-sync-editor-ownership.test.ts": 1610, + "src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts": 920, + "src/renderer/src/runtime/web-session-tabs-sync-emptied-workspace-tombstone.test.ts": 907, + "src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts": 904, + "src/renderer/src/runtime/web-session-tabs-sync-host-restart-browser-rows.test.ts": 889, + "src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts": 1787, + "src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts": 907, + "src/renderer/src/runtime/web-session-tabs-sync-initial-terminal-relaunch.test.tsx": 1999, + "src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts": 856, + "src/renderer/src/runtime/web-session-tabs-sync-layout-groups.test.ts": 849, + "src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 1630, + "src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts": 1875, + "src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx": 2282, + "src/renderer/src/runtime/web-session-tabs-sync-remote-main-agent-verdict.test.ts": 1574, + "src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 1543, + "src/renderer/src/runtime/web-session-tabs-sync-restored-browser-rows.test.ts": 891, + "src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 1094, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-adoption.test.ts": 847, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-authority.test.ts": 752, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts": 594, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts": 835, + "src/renderer/src/runtime/web-session-tabs-sync-tracking-teardown.test.ts": 764, + "src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx": 1909, + "src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx": 1961, + "src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx": 8419, + "src/renderer/src/runtime/web-session-tabs-sync.test.ts": 936, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts": 1550, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.test.ts": 1499, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts": 1593, + "src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts": 1249, + "src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts": 50, + "src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts": 1895, + "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts": 57, + "src/renderer/src/runtime/web-session-terminal-orphan-absence-across-republication.test.ts": 646, + "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts": 1437, + "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts": 911, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts": 983, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts": 568, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-prior-removal.test.ts": 563, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts": 1006, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts": 760, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts": 719, + "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts": 679, + "src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.test.ts": 750, + "src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts": 528, + "src/renderer/src/runtime/window-visibility-subscription-parking-stream-end.test.ts": 189, + "src/renderer/src/runtime/window-visibility-subscription-parking.test.ts": 218, "src/renderer/src/runtime/worktree-create-base.test.ts": 45, - "src/renderer/src/ssh/ssh-connect-in-flight.test.ts": 69, - "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 51, - "src/renderer/src/ssh/ssh-connect-verb.test.ts": 35, - "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 50, - "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 68, - "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 68, - "src/renderer/src/startup/startup-ssh-connection-restore.test.ts": 51, - "src/renderer/src/store/active-terminal-chrome-selector.test.ts": 44, - "src/renderer/src/store/always-mounted-selector-scan-cost.test.ts": 1670, - "src/renderer/src/store/copy-on-write-record.test.ts": 31, - "src/renderer/src/store/folder-workspaces/folder-workspace-catalog.test.ts": 51, - "src/renderer/src/store/github/cache-persistence.test.ts": 1363, - "src/renderer/src/store/github/refresh-event-noop-notification.test.ts": 1047, - "src/renderer/src/store/pinned-tab-close-guard.test.ts": 44, - "src/renderer/src/store/plugin-language-packs.test.ts": 321, - "src/renderer/src/store/plugin-panels.test.ts": 372, - "src/renderer/src/store/project-host-setup-selector.test.ts": 92, - "src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts": 57, - "src/renderer/src/store/projects/project-wsl-filesystem-boundary-advisory.test.ts": 64, - "src/renderer/src/store/react-commit-cascade-write-probe.test.ts": 56, - "src/renderer/src/store/repos/safe-auto-fork-sync.test.ts": 349, - "src/renderer/src/store/right-sidebar-route.test.ts": 48, - "src/renderer/src/store/running-terminal-close-confirm.test.ts": 64, - "src/renderer/src/store/selectors.test.ts": 855, - "src/renderer/src/store/slices/active-tab-owner-worktree.test.ts": 35, - "src/renderer/src/store/slices/activity-cleared-at.test.ts": 1576, - "src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 1560, - "src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 1193, - "src/renderer/src/store/slices/agent-pane-authority.test.ts": 1347, - "src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts": 1530, - "src/renderer/src/store/slices/agent-status-batch.test.ts": 1745, - "src/renderer/src/store/slices/agent-status-drop-ipc.test.ts": 2454, - "src/renderer/src/store/slices/agent-status-drop.test.ts": 1626, - "src/renderer/src/store/slices/agent-status-freshness-cache.test.ts": 53, - "src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts": 66, - "src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts": 105, - "src/renderer/src/store/slices/agent-status-live-map-leak.test.ts": 14465, - "src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts": 1154, - "src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts": 1706, - "src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts": 1545, - "src/renderer/src/store/slices/agent-status-pane-keyed-records.test.ts": 81, - "src/renderer/src/store/slices/agent-status-pr-refresh-handoff.test.ts": 1297, - "src/renderer/src/store/slices/agent-status-provider-session.test.ts": 1545, - "src/renderer/src/store/slices/agent-status-quit-capture-resumable-agents.test.ts": 1507, - "src/renderer/src/store/slices/agent-status-quit-capture.test.ts": 1864, - "src/renderer/src/store/slices/agent-status-reminted-pane-key.test.ts": 1189, - "src/renderer/src/store/slices/agent-status-retained-leak.test.ts": 1428, - "src/renderer/src/store/slices/agent-status-retention-prefix-sweep.test.ts": 1493, - "src/renderer/src/store/slices/agent-status-runtime-orchestration.test.ts": 1552, - "src/renderer/src/store/slices/agent-status-session-boundary-done.test.ts": 1464, - "src/renderer/src/store/slices/agent-status-ssh-connection-clear.test.ts": 1418, - "src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts": 1612, - "src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts": 1555, - "src/renderer/src/store/slices/agent-status.test.ts": 1634, - "src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts": 1263, - "src/renderer/src/store/slices/browser-cleanup-close.test.ts": 1227, - "src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts": 1104, - "src/renderer/src/store/slices/browser-page-conversion.test.ts": 1151, - "src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts": 575, - "src/renderer/src/store/slices/browser-remote-tab-creation.test.ts": 381, - "src/renderer/src/store/slices/browser-session-host-selection.test.ts": 570, - "src/renderer/src/store/slices/browser-session-profiles.test.ts": 260, - "src/renderer/src/store/slices/browser-webview-cleanup.test.ts": 58, - "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts": 1651, - "src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts": 331, - "src/renderer/src/store/slices/browser.test.ts": 315, - "src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts": 959, - "src/renderer/src/store/slices/cmd-j-create-actions.test.ts": 1073, - "src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts": 1538, - "src/renderer/src/store/slices/degraded-repo-hydration.test.ts": 1492, - "src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts": 998, - "src/renderer/src/store/slices/detected-agents.test.ts": 330, - "src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts": 66, - "src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts": 270, - "src/renderer/src/store/slices/diffComments.test.ts": 948, - "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts": 951, - "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts": 1210, - "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts": 1067, - "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts": 75, - "src/renderer/src/store/slices/editor-branch-diff-snapshots.test.ts": 600, - "src/renderer/src/store/slices/editor-branch-line-total.test.ts": 345, - "src/renderer/src/store/slices/editor-check-details-tabs.test.ts": 568, - "src/renderer/src/store/slices/editor-close-file-cleanup.test.ts": 563, - "src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts": 392, - "src/renderer/src/store/slices/editor-hydration-scaling.test.ts": 1744, - "src/renderer/src/store/slices/editor-markdown-link-activation.test.ts": 432, - "src/renderer/src/store/slices/editor-markdown-view-state.test.ts": 273, - "src/renderer/src/store/slices/editor-open-diff.test.ts": 567, - "src/renderer/src/store/slices/editor-preview-tab-setting.test.ts": 318, - "src/renderer/src/store/slices/editor-read-only-tabs.test.ts": 391, - "src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts": 635, - "src/renderer/src/store/slices/editor-rekey-open-files.test.ts": 918, - "src/renderer/src/store/slices/editor-remote-branch-actions.test.ts": 1644, - "src/renderer/src/store/slices/editor-rich-markdown-size-override.test.ts": 519, - "src/renderer/src/store/slices/editor-right-sidebar-state.test.ts": 451, - "src/renderer/src/store/slices/editor-session-duplicate-restore.test.ts": 1199, - "src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts": 1615, - "src/renderer/src/store/slices/editor-tab-placement.test.ts": 440, - "src/renderer/src/store/slices/editor/actions/editor-cursor-line.test.ts": 537, - "src/renderer/src/store/slices/editor/actions/editor-draft-state.test.ts": 351, - "src/renderer/src/store/slices/editor/actions/markdown-preview-actions.test.ts": 121, - "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts": 788, - "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts": 367, - "src/renderer/src/store/slices/editor/git/git-status-reconciliation.perf.test.ts": 71, - "src/renderer/src/store/slices/empty-update-notifications.test.ts": 1193, - "src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts": 1724, - "src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts": 540, - "src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts": 1058, - "src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts": 1546, - "src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts": 232, - "src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts": 472, - "src/renderer/src/store/slices/github-checks-cache.test.ts": 227, - "src/renderer/src/store/slices/github-checks.test.ts": 49, - "src/renderer/src/store/slices/github-issue-source-indicator-suppression.test.ts": 261, - "src/renderer/src/store/slices/github-issue-state-machine.test.ts": 219, - "src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts": 182, - "src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts": 183, - "src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts": 224, - "src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts": 218, - "src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts": 195, - "src/renderer/src/store/slices/github-pr-checks-fetch.test.ts": 260, - "src/renderer/src/store/slices/github-pr-comments.test.ts": 338, - "src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts": 165, - "src/renderer/src/store/slices/github-pr-refresh-hosted-review-cache-leak.test.ts": 878, - "src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts": 387, - "src/renderer/src/store/slices/github-pr-refresh-sequences-leak.test.ts": 132, - "src/renderer/src/store/slices/github-pr-refresh-states-leak.test.ts": 4096, - "src/renderer/src/store/slices/github-project-request-coordination.test.ts": 199, - "src/renderer/src/store/slices/github-project-row-owner.test.ts": 58, - "src/renderer/src/store/slices/github-project-view-tables.test.ts": 260, - "src/renderer/src/store/slices/github-provider-request-concurrency.test.ts": 214, - "src/renderer/src/store/slices/github-refresh-sweep.test.ts": 487, - "src/renderer/src/store/slices/github-repo-lookup-index.test.ts": 42, - "src/renderer/src/store/slices/github-review-thread-actions.test.ts": 105, - "src/renderer/src/store/slices/github-work-item-cache-identity.test.ts": 165, - "src/renderer/src/store/slices/github-work-items-error-envelope.test.ts": 211, - "src/renderer/src/store/slices/github-work-items-pagination.test.ts": 147, - "src/renderer/src/store/slices/github-work-items-query-bounds.test.ts": 43, - "src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts": 433, - "src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts": 251, - "src/renderer/src/store/slices/hosted-review-cache-race.test.ts": 63, - "src/renderer/src/store/slices/hosted-review-cache.test.ts": 112, - "src/renderer/src/store/slices/hosted-review.test.ts": 74, - "src/renderer/src/store/slices/jira.test.ts": 234, - "src/renderer/src/store/slices/linear-credential-error-recovery.test.ts": 124, - "src/renderer/src/store/slices/linear-invalidation.test.ts": 174, - "src/renderer/src/store/slices/linear-issue-cache-refresh.test.ts": 190, - "src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts": 220, - "src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts": 257, - "src/renderer/src/store/slices/linear.test.ts": 172, - "src/renderer/src/store/slices/local-detected-agent-state.test.ts": 217, - "src/renderer/src/store/slices/memory.test.ts": 44, - "src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts": 1308, - "src/renderer/src/store/slices/new-issue-draft.test.ts": 32, - "src/renderer/src/store/slices/new-markdown.test.ts": 548, - "src/renderer/src/store/slices/orca-profiles-auth-actions-connect-overlap.test.ts": 1110, - "src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts": 1336, - "src/renderer/src/store/slices/orca-profiles.test.ts": 1591, - "src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts": 36, - "src/renderer/src/store/slices/pane-foreground-agent.test.ts": 1584, - "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts": 31, - "src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts": 46, - "src/renderer/src/store/slices/preflight.test.ts": 198, - "src/renderer/src/store/slices/project-group-removal-targets.test.ts": 73, - "src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts": 979, - "src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts": 1687, - "src/renderer/src/store/slices/rate-limits.test.ts": 40, - "src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts": 55, - "src/renderer/src/store/slices/recently-closed-tabs.test.ts": 1723, - "src/renderer/src/store/slices/remote-server-updates.integration.test.ts": 239, - "src/renderer/src/store/slices/repo-identity-reconcile.test.ts": 55, - "src/renderer/src/store/slices/repo-owner-cache-identity.test.ts": 36, - "src/renderer/src/store/slices/repo-reorder-host-split.test.ts": 29, - "src/renderer/src/store/slices/repos-add-races.test.ts": 934, - "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts": 774, - "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts": 1525, - "src/renderer/src/store/slices/repos-all-hosts.test.ts": 1100, - "src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts": 1706, - "src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts": 1577, - "src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts": 1132, - "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts": 1217, - "src/renderer/src/store/slices/repos-host-identity-routing.test.ts": 1791, - "src/renderer/src/store/slices/repos-manual-order-hydration.test.ts": 1316, - "src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts": 1062, - "src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts": 1660, - "src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts": 1438, - "src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts": 1911, - "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts": 1182, - "src/renderer/src/store/slices/repos-project-group-create-race.test.ts": 1670, - "src/renderer/src/store/slices/repos-project-groups-delete.test.ts": 1213, - "src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts": 1649, - "src/renderer/src/store/slices/repos-project-groups.test.ts": 2702, - "src/renderer/src/store/slices/repos-project-host-capability.test.ts": 1491, - "src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts": 1516, - "src/renderer/src/store/slices/repos-project-runtime.test.ts": 1510, - "src/renderer/src/store/slices/repos-refresh-identity.test.ts": 1727, - "src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts": 1182, - "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts": 1574, - "src/renderer/src/store/slices/repos-runtime-project-groups.test.ts": 1141, - "src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts": 1266, - "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts": 1652, - "src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts": 1196, - "src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts": 1489, - "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts": 1869, - "src/renderer/src/store/slices/repos-stale-fetch.test.ts": 1671, - "src/renderer/src/store/slices/repos-update-serialization.test.ts": 1181, - "src/renderer/src/store/slices/repos.runtime-fallback.test.ts": 1080, - "src/renderer/src/store/slices/repos.test.ts": 1828, - "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts": 2296, - "src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts": 964, - "src/renderer/src/store/slices/runtime-environment-ssh.test.ts": 1253, - "src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts": 1218, - "src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts": 27, - "src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts": 1555, - "src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts": 1572, - "src/renderer/src/store/slices/runtime-status-first-publication.test.ts": 231, - "src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts": 940, - "src/renderer/src/store/slices/runtime-status-refresh-diagnostics.test.ts": 43, - "src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts": 85, - "src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts": 1361, - "src/renderer/src/store/slices/runtime-status-snapshot.test.ts": 159, - "src/renderer/src/store/slices/runtime-status.test.ts": 744, - "src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts": 1535, - "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts": 1441, - "src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts": 1292, - "src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts": 1693, - "src/renderer/src/store/slices/settings-search-state.test.ts": 28, - "src/renderer/src/store/slices/settings.test.ts": 1774, - "src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts": 1003, - "src/renderer/src/store/slices/sparse-presets.test.ts": 154, - "src/renderer/src/store/slices/ssh-target-cleanup.test.ts": 1183, - "src/renderer/src/store/slices/ssh.test.ts": 1594, - "src/renderer/src/store/slices/stale-runtime-host-rows.test.ts": 66, - "src/renderer/src/store/slices/store-active-worktree-selection.test.ts": 1363, - "src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts": 936, - "src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts": 1825, - "src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts": 1764, - "src/renderer/src/store/slices/store-create-tab-id-hint.test.ts": 1193, - "src/renderer/src/store/slices/store-session-browser-hydration.test.ts": 1304, - "src/renderer/src/store/slices/store-session-cascades.test.ts": 1595, - "src/renderer/src/store/slices/store-session-editor-hydration.test.ts": 1660, - "src/renderer/src/store/slices/store-session-terminal-activity.test.ts": 1743, - "src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts": 1259, - "src/renderer/src/store/slices/store-session-workspace-hydration.test.ts": 1696, - "src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts": 1821, - "src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts": 1576, - "src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts": 4925, - "src/renderer/src/store/slices/store-sleep-runtime-convergence.test.ts": 2006, - "src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts": 1064, - "src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 1619, - "src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 47, - "src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 44, - "src/renderer/src/store/slices/tab-group-state.test.ts": 48, - "src/renderer/src/store/slices/tab-view-mode.test.ts": 1209, - "src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 1020, - "src/renderer/src/store/slices/tabs-empty-split-activation.test.ts": 1261, - "src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts": 30, - "src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts": 74, - "src/renderer/src/store/slices/tabs-hydration.test.ts": 40, - "src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts": 963, - "src/renderer/src/store/slices/tabs-model-reconciliation.test.ts": 1467, - "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts": 1598, - "src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts": 1646, - "src/renderer/src/store/slices/tabs-session-hydration.test.ts": 1685, - "src/renderer/src/store/slices/tabs-unread-and-focus.test.ts": 931, - "src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts": 1267, - "src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts": 79, - "src/renderer/src/store/slices/tabs/tabs-reconciliation-batch-identity.test.ts": 1578, - "src/renderer/src/store/slices/task-creation-drafts.test.ts": 57, - "src/renderer/src/store/slices/terminal-helpers.test.ts": 83, - "src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts": 1592, - "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts": 1199, - "src/renderer/src/store/slices/terminal-orphan-helpers.test.ts": 157, - "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts": 1531, - "src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts": 1305, - "src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts": 187, - "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts": 1171, - "src/renderer/src/store/slices/terminal-quick-command-hosts.test.ts": 1465, - "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts": 1601, - "src/renderer/src/store/slices/terminal-tab-id-hydration.test.ts": 1215, - "src/renderer/src/store/slices/terminal-tab-owner-index.test.ts": 42, - "src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts": 1542, - "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts": 1666, - "src/renderer/src/store/slices/terminal-tab-retirement.test.ts": 195, - "src/renderer/src/store/slices/terminal-tab-title-batch.test.ts": 1100, - "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts": 1607, - "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts": 1078, - "src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts": 1366, - "src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts": 1490, - "src/renderer/src/store/slices/terminals-hydration.test.ts": 2826, - "src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts": 355, - "src/renderer/src/store/slices/ui-agent-send-target.test.ts": 413, - "src/renderer/src/store/slices/ui-contextual-tours.test.ts": 211, - "src/renderer/src/store/slices/ui-feature-interactions.test.ts": 408, - "src/renderer/src/store/slices/ui-hydration-view-layout.test.ts": 224, - "src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts": 284, - "src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts": 462, - "src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts": 580, - "src/renderer/src/store/slices/ui-new-workspace-draft.test.ts": 247, - "src/renderer/src/store/slices/ui-notice-dismissals.test.ts": 465, - "src/renderer/src/store/slices/ui-page-navigation.test.ts": 403, - "src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts": 1254, - "src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts": 55, - "src/renderer/src/store/slices/usage-web-client-fallback.test.ts": 51, - "src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 62, - "src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 369, - "src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 404, - "src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 1418, - "src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 196, - "src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 260, - "src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 64, - "src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 818, - "src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 138, - "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 1879, - "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 1391, - "src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 1426, - "src/renderer/src/store/slices/workspace-space.test.ts": 40, - "src/renderer/src/store/slices/worktree-by-id-index.test.ts": 52, - "src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 38, - "src/renderer/src/store/slices/worktree-helpers.test.ts": 55, - "src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 52, - "src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 71, - "src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 68, - "src/renderer/src/store/slices/worktree-nav-history.test.ts": 51, - "src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 1074, - "src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 1593, - "src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 1486, - "src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 67, - "src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 1508, - "src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 937, - "src/renderer/src/store/slices/worktrees-create-base-status.test.ts": 516, - "src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts": 587, - "src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts": 520, - "src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts": 1805, - "src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts": 566, - "src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts": 689, - "src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts": 377, - "src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts": 920, - "src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts": 629, - "src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts": 515, - "src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts": 1035, - "src/renderer/src/store/slices/worktrees-hydration-purge.test.ts": 604, - "src/renderer/src/store/slices/worktrees-identity-migration.test.ts": 1146, - "src/renderer/src/store/slices/worktrees-lineage-state.test.ts": 642, - "src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts": 506, - "src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts": 853, - "src/renderer/src/store/slices/worktrees-pending-creation-state.test.ts": 915, - "src/renderer/src/store/slices/worktrees-purge-terminal-state.test.ts": 442, - "src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts": 749, - "src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts": 909, - "src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts": 949, - "src/renderer/src/store/slices/worktrees-runtime-connection-generation-fence.test.ts": 1207, - "src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts": 819, - "src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts": 729, - "src/renderer/src/store/slices/worktrees-unread-state.test.ts": 663, - "src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts": 726, - "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 102, - "src/renderer/src/store/slices/worktrees/listing/detected-worktree-unavailable-reason.test.ts": 52, - "src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts": 1634, - "src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts": 178, - "src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts": 842, - "src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts": 839, - "src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts": 673, - "src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts": 39, - "src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts": 340, - "src/renderer/src/store/slices/worktrees/teardown/worktree-teardown-array-identity.test.ts": 228, - "src/renderer/src/store/store-identity-churn-probe.test.ts": 73, - "src/renderer/src/store/store-listener-census.test.ts": 32, - "src/renderer/src/store/terminals/restored-relay-session-identity.test.ts": 1454, - "src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts": 1252, - "src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts": 1508, - "src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx": 1675, - "src/renderer/src/store/terminals/terminal-shutdown-guards-identity.test.ts": 39, - "src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts": 213, - "src/renderer/src/store/terminals/terminal-tab-close-map-identity.test.ts": 2043, - "src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts": 1822, - "src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts": 182, - "src/renderer/src/store/terminals/workspace-terminal-placeholders.test.ts": 102, - "src/renderer/src/store/worktree-diff-comments-selector.test.ts": 86, - "src/renderer/src/store/worktree-host-collision-index.test.ts": 53, - "src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts": 36, - "src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts": 195, - "src/renderer/src/web/preload-api/web-gitlab-api.test.ts": 45, - "src/renderer/src/web/web-clipboard-copy-fallback.test.ts": 75, - "src/renderer/src/web/web-clipboard-copy-terminal-selection.test.ts": 443, - "src/renderer/src/web/web-file-mutation-methods.test.ts": 234, - "src/renderer/src/web/web-pairing.test.ts": 54, - "src/renderer/src/web/web-preload-api-agent-providers.test.ts": 494, - "src/renderer/src/web/web-preload-api-clipboard.test.ts": 940, - "src/renderer/src/web/web-preload-api-composition.test.ts": 532, - "src/renderer/src/web/web-preload-api-filesystem.test.ts": 519, - "src/renderer/src/web/web-preload-api-git.test.ts": 455, - "src/renderer/src/web/web-preload-api-github.test.ts": 506, - "src/renderer/src/web/web-preload-api-gitlab.test.ts": 962, - "src/renderer/src/web/web-preload-api-keybindings.test.ts": 505, - "src/renderer/src/web/web-preload-api-runtime-calls.test.ts": 554, - "src/renderer/src/web/web-preload-api-runtime-environment.test.ts": 1261, - "src/renderer/src/web/web-preload-api-settings.test.ts": 1773, - "src/renderer/src/web/web-preload-api-ssh.test.ts": 398, - "src/renderer/src/web/web-preload-api-ui.test.ts": 1867, - "src/renderer/src/web/web-preload-api-workspace-catalog.test.ts": 1039, - "src/renderer/src/web/web-preload-updater-package-recovery.test.ts": 292, - "src/renderer/src/web/web-runtime-client-export-parity.test.ts": 214, - "src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts": 282, - "src/renderer/src/web/web-runtime-client-heartbeat.test.ts": 254, - "src/renderer/src/web/web-runtime-client-timeout-budget.test.ts": 217, - "src/renderer/src/web/web-runtime-client.test.ts": 593, - "src/renderer/src/web/web-runtime-connection-frame-router.test.ts": 140, - "src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts": 76, - "src/renderer/src/web/web-runtime-direct-proxy-live.integration.test.ts": 138, - "src/renderer/src/web/web-runtime-status-owner.test.ts": 882, - "src/renderer/src/web/web-shell-paths-exist.test.ts": 34, - "src/renderer/src/web/web-viewport-shell.test.ts": 48, - "src/renderer/src/web/web-workspace-session.test.ts": 169, - "src/shared/absolute-dir-override.test.ts": 80, - "src/shared/add-repo-existing-workspaces-telemetry.test.ts": 104, - "src/shared/agent-cli-flag-detection.test.ts": 40, - "src/shared/agent-cli-install-dir-fallback.test.ts": 65, - "src/shared/agent-decorative-title-signature.test.ts": 54, - "src/shared/agent-detection.test.ts": 112, - "src/shared/agent-feature-install-commands.test.ts": 48, - "src/shared/agent-hook-endpoint-file.test.ts": 36, - "src/shared/agent-hook-endpoint-temp-cleanup.test.ts": 53, - "src/shared/agent-hook-listener-antigravity.test.ts": 102, - "src/shared/agent-hook-listener-claude-compatible-vendors.test.ts": 174, - "src/shared/agent-hook-listener-claude-subagents.test.ts": 150, - "src/shared/agent-hook-listener-claude-turn-state.test.ts": 112, - "src/shared/agent-hook-listener-codex-approval-ownership.test.ts": 174, - "src/shared/agent-hook-listener-codex-main-agent.test.ts": 165, - "src/shared/agent-hook-listener-command-code-transcript.test.ts": 126, - "src/shared/agent-hook-listener-extraction-characterization.test.ts": 89, - "src/shared/agent-hook-listener-grok-completion.test.ts": 155, - "src/shared/agent-hook-listener-grok.test.ts": 160, - "src/shared/agent-hook-listener-hermes-codex-droid.test.ts": 206, - "src/shared/agent-hook-listener-interactive-prompts.test.ts": 207, - "src/shared/agent-hook-listener-omp-approval-ownership.test.ts": 166, - "src/shared/agent-hook-listener-opencode-reattribution.test.ts": 154, - "src/shared/agent-hook-listener-opencode-session-correlation.test.ts": 44, - "src/shared/agent-hook-listener-opencode-session-registry.test.ts": 38, - "src/shared/agent-hook-listener-pi-compatible.test.ts": 89, - "src/shared/agent-hook-listener-relay-dependency.test.ts": 33, - "src/shared/agent-hook-listener-roster-retention.test.ts": 177, - "src/shared/agent-hook-listener-session-replacement.test.ts": 130, - "src/shared/agent-hook-listener-startless-child-lifecycle.test.ts": 180, - "src/shared/agent-hook-listener-transport.test.ts": 151, - "src/shared/agent-hook-listener/providers/muse-events.test.ts": 192, - "src/shared/agent-hook-listener/providers/zcode-events.test.ts": 107, - "src/shared/agent-hook-listener/transcript-reader.test.ts": 170, - "src/shared/agent-hook-relay.test.ts": 34, - "src/shared/agent-hook-request-body-memory.test.ts": 63, - "src/shared/agent-hook-spool-read.test.ts": 72, - "src/shared/agent-hook-status-cache.test.ts": 60, - "src/shared/agent-hook-transport-interference.test.ts": 64, - "src/shared/agent-image-paste.test.ts": 34, - "src/shared/agent-kind.test.ts": 131, - "src/shared/agent-launch-operation.test.ts": 43, - "src/shared/agent-launch-preferences.test.ts": 47, - "src/shared/agent-launch-remote.test.ts": 45, - "src/shared/agent-lead-status-fold.test.ts": 93, - "src/shared/agent-model-probe-spec.test.ts": 61, - "src/shared/agent-notification-id.test.ts": 28, - "src/shared/agent-process-recognition.test.ts": 139, - "src/shared/agent-prompt-injection.test.ts": 59, - "src/shared/agent-resume-argv-drop.test.ts": 87, - "src/shared/agent-resume-launch-command.test.ts": 87, - "src/shared/agent-row-conversation-name.test.ts": 51, - "src/shared/agent-scratch-worktrees.test.ts": 67, - "src/shared/agent-session-conversation-name.test.ts": 32, - "src/shared/agent-session-definitive-refusal.test.ts": 100, - "src/shared/agent-session-fence-mint-boundary.test.ts": 2032, - "src/shared/agent-session-journal-producer.test.ts": 36, - "src/shared/agent-session-journal-schemas.test.ts": 184, - "src/shared/agent-session-lease-adjudication.test.ts": 40, - "src/shared/agent-session-legacy-handoff-lease.test.ts": 89, - "src/shared/agent-session-mutation-envelope.test.ts": 183, - "src/shared/agent-session-operation-ledger.test.ts": 115, - "src/shared/agent-session-option-catalog-grok.test.ts": 106, + "src/renderer/src/ssh/ssh-connect-in-flight.test.ts": 77, + "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 58, + "src/renderer/src/ssh/ssh-connect-verb.test.ts": 57, + "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 52, + "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 70, + "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 65, + "src/renderer/src/startup/startup-degraded-recovery.test.ts": 408, + "src/renderer/src/startup/startup-ssh-connection-restore.test.ts": 77, + "src/renderer/src/store/active-terminal-chrome-selector.test.ts": 52, + "src/renderer/src/store/adopt-from-endpoint.test.ts": 344, + "src/renderer/src/store/always-mounted-selector-scan-cost.test.ts": 1434, + "src/renderer/src/store/copy-on-write-record.test.ts": 46, + "src/renderer/src/store/folder-workspaces/folder-workspace-catalog.test.ts": 378, + "src/renderer/src/store/github/cache-persistence.test.ts": 1784, + "src/renderer/src/store/github/check-actions-errors.test.ts": 192, + "src/renderer/src/store/github/project-field-mutations.test.ts": 421, + "src/renderer/src/store/github/provider-request-coalescing.test.ts": 542, + "src/renderer/src/store/github/refresh-event-noop-notification.test.ts": 1347, + "src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.test.ts": 91, + "src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts": 490, + "src/renderer/src/store/github/work-item-routing.test.ts": 281, + "src/renderer/src/store/host-catalog-fencing.test.ts": 75, + "src/renderer/src/store/pinned-tab-close-guard.test.ts": 53, + "src/renderer/src/store/plugin-language-packs-startup.test.ts": 375, + "src/renderer/src/store/plugin-language-packs.test.ts": 206, + "src/renderer/src/store/plugin-panel-retry-timer-cleanup.test.ts": 103, + "src/renderer/src/store/plugin-panels.test.ts": 145, + "src/renderer/src/store/project-host-setup-selector.test.ts": 76, + "src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts": 101, + "src/renderer/src/store/projects/project-wsl-filesystem-boundary-advisory.test.ts": 63, + "src/renderer/src/store/react-commit-cascade-write-probe.test.ts": 80, + "src/renderer/src/store/repos/converted-ssh-browser-pages.test.ts": 349, + "src/renderer/src/store/repos/converted-ssh-host-rows.test.ts": 60, + "src/renderer/src/store/repos/repo-removal.test.ts": 324, + "src/renderer/src/store/repos/safe-auto-fork-sync.test.ts": 320, + "src/renderer/src/store/right-sidebar-route.test.ts": 55, + "src/renderer/src/store/running-terminal-close-confirm.test.ts": 73, + "src/renderer/src/store/selectors.test.ts": 1496, + "src/renderer/src/store/settled-sort-epoch.test.ts": 1678, + "src/renderer/src/store/slices/active-selection-owner-scoping.test.ts": 1368, + "src/renderer/src/store/slices/active-tab-owner-worktree.test.ts": 70, + "src/renderer/src/store/slices/activity-cleared-at.test.ts": 1529, + "src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 1429, + "src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 1457, + "src/renderer/src/store/slices/agent-pane-authority.test.ts": 1460, + "src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts": 1469, + "src/renderer/src/store/slices/agent-status-batch.test.ts": 1496, + "src/renderer/src/store/slices/agent-status-cursor-checkpoint.test.ts": 1418, + "src/renderer/src/store/slices/agent-status-drop-ipc.test.ts": 1802, + "src/renderer/src/store/slices/agent-status-freshness-cache.test.ts": 68, + "src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts": 63, + "src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts": 143, + "src/renderer/src/store/slices/agent-status-live-map-leak.test.ts": 7374, + "src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts": 1387, + "src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts": 1473, + "src/renderer/src/store/slices/agent-status-pane-keyed-records.test.ts": 84, + "src/renderer/src/store/slices/agent-status-pr-refresh-handoff.test.ts": 1504, + "src/renderer/src/store/slices/agent-status-provider-session.test.ts": 1961, + "src/renderer/src/store/slices/agent-status-quit-capture-resumable-agents.test.ts": 1365, + "src/renderer/src/store/slices/agent-status-quit-capture.test.ts": 1559, + "src/renderer/src/store/slices/agent-status-reminted-pane-key.test.ts": 1580, + "src/renderer/src/store/slices/agent-status-retention-prefix-sweep.test.ts": 1850, + "src/renderer/src/store/slices/agent-status-runtime-orchestration.test.ts": 1464, + "src/renderer/src/store/slices/agent-status-session-boundary-done.test.ts": 1563, + "src/renderer/src/store/slices/agent-status-ssh-connection-clear.test.ts": 1659, + "src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts": 1413, + "src/renderer/src/store/slices/agent-status-turn-started-at.test.ts": 1312, + "src/renderer/src/store/slices/agent-status-verdict-copies.test.ts": 431, + "src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts": 1377, + "src/renderer/src/store/slices/agent-status.test.ts": 1842, + "src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts": 1533, + "src/renderer/src/store/slices/browser-annotation-retention.test.ts": 603, + "src/renderer/src/store/slices/browser-cleanup-close.test.ts": 1587, + "src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts": 1828, + "src/renderer/src/store/slices/browser-page-conversion.test.ts": 1558, + "src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts": 671, + "src/renderer/src/store/slices/browser-remote-tab-creation.test.ts": 384, + "src/renderer/src/store/slices/browser-session-host-selection.test.ts": 672, + "src/renderer/src/store/slices/browser-session-profiles.test.ts": 434, + "src/renderer/src/store/slices/browser-tab-creation-placement.test.ts": 1348, + "src/renderer/src/store/slices/browser-webview-cleanup.test.ts": 76, + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts": 1525, + "src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts": 441, + "src/renderer/src/store/slices/browser.test.ts": 560, + "src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts": 1419, + "src/renderer/src/store/slices/cmd-j-create-actions.test.ts": 1303, + "src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts": 1446, + "src/renderer/src/store/slices/degraded-repo-hydration.test.ts": 1498, + "src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts": 1505, + "src/renderer/src/store/slices/detected-agents.test.ts": 482, + "src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts": 61, + "src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts": 179, + "src/renderer/src/store/slices/diffComments.test.ts": 1533, + "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts": 1425, + "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts": 1416, + "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts": 1261, + "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts": 86, + "src/renderer/src/store/slices/editor-branch-diff-snapshots.test.ts": 765, + "src/renderer/src/store/slices/editor-branch-line-total.test.ts": 637, + "src/renderer/src/store/slices/editor-chat-visual-tabs.test.ts": 700, + "src/renderer/src/store/slices/editor-check-details-tabs.test.ts": 745, + "src/renderer/src/store/slices/editor-close-document-siblings.test.ts": 1085, + "src/renderer/src/store/slices/editor-close-file-cleanup.test.ts": 1090, + "src/renderer/src/store/slices/editor-close-file-stale-active.test.ts": 1353, + "src/renderer/src/store/slices/editor-close-workspace-selection.test.ts": 784, + "src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts": 712, + "src/renderer/src/store/slices/editor-hydration-scaling.test.ts": 1448, + "src/renderer/src/store/slices/editor-markdown-link-activation.test.ts": 803, + "src/renderer/src/store/slices/editor-markdown-view-state.test.ts": 756, + "src/renderer/src/store/slices/editor-open-diff.test.ts": 831, + "src/renderer/src/store/slices/editor-preview-tab-setting.test.ts": 735, + "src/renderer/src/store/slices/editor-read-only-tabs.test.ts": 625, + "src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts": 882, + "src/renderer/src/store/slices/editor-rekey-open-files.test.ts": 1416, + "src/renderer/src/store/slices/editor-remote-branch-actions.test.ts": 766, + "src/renderer/src/store/slices/editor-rich-markdown-size-override.test.ts": 649, + "src/renderer/src/store/slices/editor-right-sidebar-state.test.ts": 860, + "src/renderer/src/store/slices/editor-session-duplicate-restore.test.ts": 1413, + "src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts": 1441, + "src/renderer/src/store/slices/editor-tab-host-ownership.test.ts": 651, + "src/renderer/src/store/slices/editor-tab-placement.test.ts": 729, + "src/renderer/src/store/slices/editor/actions/editor-cursor-line.test.ts": 667, + "src/renderer/src/store/slices/editor/actions/editor-draft-state.test.ts": 645, + "src/renderer/src/store/slices/editor/actions/markdown-preview-actions.test.ts": 294, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts": 484, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts": 451, + "src/renderer/src/store/slices/editor/git/git-status-reconciliation.perf.test.ts": 84, + "src/renderer/src/store/slices/empty-update-notifications.test.ts": 1428, + "src/renderer/src/store/slices/floating-workspace-row.test.ts": 1455, + "src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts": 1470, + "src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts": 809, + "src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts": 1733, + "src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts": 1460, + "src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts": 229, + "src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts": 330, + "src/renderer/src/store/slices/github-checks-cache.test.ts": 235, + "src/renderer/src/store/slices/github-checks.test.ts": 56, + "src/renderer/src/store/slices/github-issue-source-indicator-suppression.test.ts": 252, + "src/renderer/src/store/slices/github-issue-state-machine.test.ts": 224, + "src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts": 247, + "src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts": 228, + "src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts": 235, + "src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts": 234, + "src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts": 248, + "src/renderer/src/store/slices/github-pr-checks-fetch.test.ts": 292, + "src/renderer/src/store/slices/github-pr-comments.test.ts": 233, + "src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts": 202, + "src/renderer/src/store/slices/github-pr-refresh-hosted-review-cache-leak.test.ts": 362, + "src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts": 364, + "src/renderer/src/store/slices/github-pr-refresh-sequences-leak.test.ts": 230, + "src/renderer/src/store/slices/github-pr-refresh-states-leak.test.ts": 903, + "src/renderer/src/store/slices/github-pr-request-lifetime.test.ts": 713, + "src/renderer/src/store/slices/github-project-request-coordination.test.ts": 233, + "src/renderer/src/store/slices/github-project-row-owner.test.ts": 53, + "src/renderer/src/store/slices/github-project-view-tables.test.ts": 237, + "src/renderer/src/store/slices/github-provider-request-concurrency.test.ts": 206, + "src/renderer/src/store/slices/github-refresh-sweep.test.ts": 348, + "src/renderer/src/store/slices/github-repo-lookup-index.test.ts": 47, + "src/renderer/src/store/slices/github-review-thread-actions.test.ts": 217, + "src/renderer/src/store/slices/github-work-item-cache-identity.test.ts": 204, + "src/renderer/src/store/slices/github-work-items-error-envelope.test.ts": 1787, + "src/renderer/src/store/slices/github-work-items-pagination.test.ts": 1619, + "src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts": 1676, + "src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts": 272, + "src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts": 1423, + "src/renderer/src/store/slices/hosted-review-cache-race.test.ts": 76, + "src/renderer/src/store/slices/hosted-review-cache.test.ts": 130, + "src/renderer/src/store/slices/hosted-review-request-lifetime.test.ts": 89, + "src/renderer/src/store/slices/hosted-review.test.ts": 130, + "src/renderer/src/store/slices/jira-connection-identity.test.ts": 90, + "src/renderer/src/store/slices/jira-web-client-status.test.ts": 126, + "src/renderer/src/store/slices/jira.test.ts": 166, + "src/renderer/src/store/slices/linear-credential-error-recovery.test.ts": 189, + "src/renderer/src/store/slices/linear-invalidation.test.ts": 142, + "src/renderer/src/store/slices/linear-issue-cache-refresh.test.ts": 177, + "src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts": 184, + "src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts": 200, + "src/renderer/src/store/slices/linear.test.ts": 168, + "src/renderer/src/store/slices/local-detected-agent-state.test.ts": 357, + "src/renderer/src/store/slices/memory.test.ts": 58, + "src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts": 1473, + "src/renderer/src/store/slices/new-issue-draft.test.ts": 50, + "src/renderer/src/store/slices/new-markdown.test.ts": 654, + "src/renderer/src/store/slices/orca-profiles-auth-actions-connect-overlap.test.ts": 1439, + "src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts": 1418, + "src/renderer/src/store/slices/orca-profiles.test.ts": 1424, + "src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts": 46, + "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts": 57, + "src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts": 69, + "src/renderer/src/store/slices/preflight.test.ts": 347, + "src/renderer/src/store/slices/project-group-removal-targets.test.ts": 69, + "src/renderer/src/store/slices/project-removal-owner-host.test.ts": 1320, + "src/renderer/src/store/slices/purge-stale-runtime-host-groups.test.ts": 1389, + "src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts": 1523, + "src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts": 1413, + "src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts": 49, + "src/renderer/src/store/slices/recently-closed-tabs.test.ts": 1467, + "src/renderer/src/store/slices/remote-server-updates.integration.test.ts": 305, + "src/renderer/src/store/slices/repo-identity-reconcile.test.ts": 72, + "src/renderer/src/store/slices/repo-owner-cache-identity.test.ts": 53, + "src/renderer/src/store/slices/repo-reorder-host-split.test.ts": 49, + "src/renderer/src/store/slices/repos-add-races.test.ts": 1465, + "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts": 1523, + "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts": 1480, + "src/renderer/src/store/slices/repos-all-hosts.test.ts": 1476, + "src/renderer/src/store/slices/repos-catalog-connection-fence.test.ts": 1552, + "src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts": 1544, + "src/renderer/src/store/slices/repos-catalog-notifications.test.ts": 1367, + "src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts": 1355, + "src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts": 1448, + "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts": 2007, + "src/renderer/src/store/slices/repos-host-identity-routing.test.ts": 1747, + "src/renderer/src/store/slices/repos-manual-order-hydration.test.ts": 1398, + "src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts": 1925, + "src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts": 1395, + "src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts": 1383, + "src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts": 1708, + "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts": 1444, + "src/renderer/src/store/slices/repos-project-group-create-race.test.ts": 1513, + "src/renderer/src/store/slices/repos-project-groups-delete.test.ts": 1217, + "src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts": 1410, + "src/renderer/src/store/slices/repos-project-groups.test.ts": 1564, + "src/renderer/src/store/slices/repos-project-host-capability.test.ts": 1159, + "src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts": 2194, + "src/renderer/src/store/slices/repos-project-runtime.test.ts": 1478, + "src/renderer/src/store/slices/repos-refresh-identity.test.ts": 1791, + "src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts": 1545, + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts": 1355, + "src/renderer/src/store/slices/repos-runtime-project-groups.test.ts": 1437, + "src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts": 1554, + "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts": 1507, + "src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts": 1336, + "src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts": 1400, + "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts": 1557, + "src/renderer/src/store/slices/repos-stale-fetch.test.ts": 1405, + "src/renderer/src/store/slices/repos-update-serialization.test.ts": 1554, + "src/renderer/src/store/slices/repos.runtime-fallback.test.ts": 1456, + "src/renderer/src/store/slices/repos.test.ts": 1736, + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts": 1695, + "src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts": 1585, + "src/renderer/src/store/slices/runtime-detected-agents.test.ts": 1466, + "src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts": 47, + "src/renderer/src/store/slices/runtime-environment-ssh.test.ts": 1392, + "src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts": 1569, + "src/renderer/src/store/slices/runtime-managed-repair-keeps-workspaces.test.ts": 1477, + "src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts": 1397, + "src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts": 1377, + "src/renderer/src/store/slices/runtime-status-first-publication.test.ts": 340, + "src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts": 1417, + "src/renderer/src/store/slices/runtime-status-refresh-diagnostics.test.ts": 83, + "src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts": 158, + "src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts": 1723, + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts": 194, + "src/renderer/src/store/slices/runtime-status.test.ts": 497, + "src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts": 1350, + "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts": 1402, + "src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts": 1800, + "src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts": 1615, + "src/renderer/src/store/slices/settings-search-state.test.ts": 57, + "src/renderer/src/store/slices/settings.test.ts": 1698, + "src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts": 1627, + "src/renderer/src/store/slices/sparse-presets.test.ts": 93, + "src/renderer/src/store/slices/ssh-target-cleanup.test.ts": 1693, + "src/renderer/src/store/slices/ssh.test.ts": 1441, + "src/renderer/src/store/slices/stale-runtime-host-rows.test.ts": 59, + "src/renderer/src/store/slices/store-active-worktree-selection.test.ts": 1522, + "src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts": 1521, + "src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts": 1491, + "src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts": 1552, + "src/renderer/src/store/slices/store-create-tab-id-hint.test.ts": 1663, + "src/renderer/src/store/slices/store-session-browser-hydration.test.ts": 1483, + "src/renderer/src/store/slices/store-session-cascades.test.ts": 1360, + "src/renderer/src/store/slices/store-session-editor-hydration.test.ts": 1343, + "src/renderer/src/store/slices/store-session-terminal-activity.test.ts": 1448, + "src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts": 1555, + "src/renderer/src/store/slices/store-session-workspace-hydration.test.ts": 1701, + "src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts": 2125, + "src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts": 1412, + "src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts": 1442, + "src/renderer/src/store/slices/store-sleep-runtime-convergence.test.ts": 1758, + "src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts": 1456, + "src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 1459, + "src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 54, + "src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 74, + "src/renderer/src/store/slices/tab-group-state.test.ts": 69, + "src/renderer/src/store/slices/tab-view-mode.test.ts": 1235, + "src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 1589, + "src/renderer/src/store/slices/tabs-close-dangling-group.test.ts": 1520, + "src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts": 67, + "src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts": 59, + "src/renderer/src/store/slices/tabs-hydration.test.ts": 68, + "src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts": 1510, + "src/renderer/src/store/slices/tabs-model-reconciliation.test.ts": 1681, + "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts": 1560, + "src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts": 1393, + "src/renderer/src/store/slices/tabs-session-hydration.test.ts": 1813, + "src/renderer/src/store/slices/tabs-unread-and-focus.test.ts": 1566, + "src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts": 1722, + "src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts": 1367, + "src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts": 67, + "src/renderer/src/store/slices/tabs/tabs-reconciliation-batch-identity.test.ts": 1401, + "src/renderer/src/store/slices/task-creation-drafts.test.ts": 45, + "src/renderer/src/store/slices/terminal-helpers.test.ts": 91, + "src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts": 1964, + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts": 1509, + "src/renderer/src/store/slices/terminal-orphan-helpers.test.ts": 393, + "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts": 1381, + "src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts": 1758, + "src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts": 420, + "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts": 1511, + "src/renderer/src/store/slices/terminal-quick-command-hosts.test.ts": 1285, + "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts": 2880, + "src/renderer/src/store/slices/terminal-tab-id-hydration.test.ts": 1368, + "src/renderer/src/store/slices/terminal-tab-owner-index.test.ts": 57, + "src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts": 1395, + "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts": 1489, + "src/renderer/src/store/slices/terminal-tab-retirement.test.ts": 446, + "src/renderer/src/store/slices/terminal-tab-title-batch.test.ts": 1406, + "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts": 1342, + "src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts": 1470, + "src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts": 1346, + "src/renderer/src/store/slices/terminals-hydration.test.ts": 1435, + "src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts": 411, + "src/renderer/src/store/slices/ui-agent-send-target.test.ts": 589, + "src/renderer/src/store/slices/ui-contextual-tours.test.ts": 556, + "src/renderer/src/store/slices/ui-feature-interactions.test.ts": 660, + "src/renderer/src/store/slices/ui-host-filter-sync.test.ts": 485, + "src/renderer/src/store/slices/ui-hydration-view-layout.test.ts": 618, + "src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts": 426, + "src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts": 520, + "src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts": 507, + "src/renderer/src/store/slices/ui-notice-dismissals.test.ts": 536, + "src/renderer/src/store/slices/ui-page-navigation.test.ts": 563, + "src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts": 1598, + "src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts": 56, + "src/renderer/src/store/slices/usage-web-client-fallback.test.ts": 44, + "src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts": 1530, + "src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 69, + "src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 551, + "src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 671, + "src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 1355, + "src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 423, + "src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 423, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 96, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 1070, + "src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 432, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 1619, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 1785, + "src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 1408, + "src/renderer/src/store/slices/workspace-space.test.ts": 55, + "src/renderer/src/store/slices/worktree-by-id-index.test.ts": 42, + "src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 49, + "src/renderer/src/store/slices/worktree-helpers.test.ts": 88, + "src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 50, + "src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 104, + "src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 111, + "src/renderer/src/store/slices/worktree-nav-history.test.ts": 100, + "src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 1546, + "src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 1394, + "src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 1404, + "src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 158, + "src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 1445, + "src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 824, + "src/renderer/src/store/slices/worktrees-always-force-delete.test.ts": 786, + "src/renderer/src/store/slices/worktrees-background-removal.test.ts": 1562, + "src/renderer/src/store/slices/worktrees-create-base-status.test.ts": 887, + "src/renderer/src/store/slices/worktrees-create-captured-host.test.ts": 787, + "src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts": 962, + "src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts": 814, + "src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts": 1908, + "src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts": 886, + "src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts": 715, + "src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts": 883, + "src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts": 800, + "src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts": 866, + "src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts": 885, + "src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts": 1395, + "src/renderer/src/store/slices/worktrees-hydration-purge.test.ts": 965, + "src/renderer/src/store/slices/worktrees-identity-migration.test.ts": 1031, + "src/renderer/src/store/slices/worktrees-lineage-state.test.ts": 863, + "src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts": 829, + "src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts": 993, + "src/renderer/src/store/slices/worktrees-multiple-attachments.test.ts": 808, + "src/renderer/src/store/slices/worktrees-pending-creation-state.test.ts": 794, + "src/renderer/src/store/slices/worktrees-purge-terminal-state.test.ts": 832, + "src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts": 867, + "src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts": 879, + "src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts": 888, + "src/renderer/src/store/slices/worktrees-runtime-connection-generation-fence.test.ts": 1005, + "src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts": 779, + "src/renderer/src/store/slices/worktrees-terminal-pr-exact-observation.test.ts": 805, + "src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts": 1028, + "src/renderer/src/store/slices/worktrees-terminal-reference-admission.test.ts": 1524, + "src/renderer/src/store/slices/worktrees-terminal-reference-origins.test.ts": 914, + "src/renderer/src/store/slices/worktrees-terminal-reference-source-race.test.ts": 720, + "src/renderer/src/store/slices/worktrees-unread-state.test.ts": 932, + "src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts": 740, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 338, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-unavailable-reason.test.ts": 325, + "src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts": 1688, + "src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts": 98, + "src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts": 359, + "src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts": 797, + "src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts": 883, + "src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts": 388, + "src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts": 50, + "src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts": 299, + "src/renderer/src/store/slices/worktrees/teardown/worktree-teardown-array-identity.test.ts": 443, + "src/renderer/src/store/store-identity-churn-probe.test.ts": 71, + "src/renderer/src/store/store-listener-census.test.ts": 59, + "src/renderer/src/store/terminals/restored-relay-session-identity.test.ts": 1782, + "src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts": 1376, + "src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts": 1393, + "src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts": 1428, + "src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx": 1787, + "src/renderer/src/store/terminals/terminal-shutdown-guards-identity.test.ts": 57, + "src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts": 245, + "src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts": 1531, + "src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts": 347, + "src/renderer/src/store/terminals/workspace-terminal-placeholders.test.ts": 273, + "src/renderer/src/store/workspace-layout-cache.test.ts": 66, + "src/renderer/src/store/worktree-diff-comments-selector.test.ts": 101, + "src/renderer/src/store/worktree-host-collision-index.test.ts": 51, + "src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts": 45, + "src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts": 52, + "src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts": 267, + "src/renderer/src/web/preload-api/web-fallback-api.test.ts": 59, + "src/renderer/src/web/preload-api/web-git-stage-scope.test.ts": 528, + "src/renderer/src/web/preload-api/web-gitlab-api.test.ts": 56, + "src/renderer/src/web/preload-api/web-ui-compact-notice.test.ts": 818, + "src/renderer/src/web/preload-api/web-ui-explorer-root.test.ts": 695, + "src/renderer/src/web/preload-api/web-worktree-attachment-mutations.test.ts": 314, + "src/renderer/src/web/web-clipboard-copy-fallback.test.ts": 46, + "src/renderer/src/web/web-clipboard-copy-terminal-selection.test.ts": 140, + "src/renderer/src/web/web-clipboard-image-presence.test.ts": 367, + "src/renderer/src/web/web-file-mutation-methods.test.ts": 281, + "src/renderer/src/web/web-native-chat-appearance-settings.test.ts": 259, + "src/renderer/src/web/web-pairing.test.ts": 250, + "src/renderer/src/web/web-preload-api-agent-providers.test.ts": 795, + "src/renderer/src/web/web-preload-api-clipboard.test.ts": 1407, + "src/renderer/src/web/web-preload-api-composition.test.ts": 768, + "src/renderer/src/web/web-preload-api-filesystem.test.ts": 694, + "src/renderer/src/web/web-preload-api-git.test.ts": 605, + "src/renderer/src/web/web-preload-api-github.test.ts": 710, + "src/renderer/src/web/web-preload-api-gitlab.test.ts": 833, + "src/renderer/src/web/web-preload-api-jira.test.ts": 1051, + "src/renderer/src/web/web-preload-api-keybindings.test.ts": 597, + "src/renderer/src/web/web-preload-api-rate-limits.test.ts": 1529, + "src/renderer/src/web/web-preload-api-runtime-calls.test.ts": 1289, + "src/renderer/src/web/web-preload-api-runtime-environment.test.ts": 1527, + "src/renderer/src/web/web-preload-api-settings.test.ts": 2698, + "src/renderer/src/web/web-preload-api-ssh.test.ts": 451, + "src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts": 2567, + "src/renderer/src/web/web-preload-api-ui.test.ts": 2101, + "src/renderer/src/web/web-preload-api-workspace-catalog.test.ts": 2033, + "src/renderer/src/web/web-preload-updater-package-recovery.test.ts": 554, + "src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts": 213, + "src/renderer/src/web/web-runtime-client-heartbeat.test.ts": 234, + "src/renderer/src/web/web-runtime-client-timeout-budget.test.ts": 264, + "src/renderer/src/web/web-runtime-client.test.ts": 603, + "src/renderer/src/web/web-runtime-connection-frame-router.test.ts": 223, + "src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts": 46, + "src/renderer/src/web/web-runtime-direct-proxy-live.integration.test.ts": 251, + "src/renderer/src/web/web-runtime-environment-repair.test.ts": 827, + "src/renderer/src/web/web-runtime-status-owner.test.ts": 981, + "src/renderer/src/web/web-shell-paths-exist.test.ts": 40, + "src/renderer/src/web/web-workspace-session.test.ts": 272, + "src/renderer/src/web/web-zcode-plan-settings.test.ts": 726, + "src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts": 98, + "src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts": 71, + "src/shared/__tests__/process-host/windows-command-line.win32.test.ts": 53, + "src/shared/abort-signal-reason.test.ts": 46, + "src/shared/absolute-dir-override.test.ts": 62, + "src/shared/add-repo-existing-workspaces-telemetry.test.ts": 281, + "src/shared/agent-child-row-model.test.ts": 85, + "src/shared/agent-cli-flag-detection.test.ts": 56, + "src/shared/agent-cli-install-dir-fallback.test.ts": 73, + "src/shared/agent-decorative-title-signature.test.ts": 67, + "src/shared/agent-detection.test.ts": 146, + "src/shared/agent-feature-install-commands.test.ts": 65, + "src/shared/agent-hook-endpoint-file.test.ts": 49, + "src/shared/agent-hook-endpoint-temp-cleanup.test.ts": 58, + "src/shared/agent-hook-listener-antigravity.test.ts": 202, + "src/shared/agent-hook-listener-claude-compatible-vendors.test.ts": 187, + "src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts": 192, + "src/shared/agent-hook-listener-claude-subagents.test.ts": 204, + "src/shared/agent-hook-listener-claude-task-notification.test.ts": 353, + "src/shared/agent-hook-listener-claude-turn-state.test.ts": 158, + "src/shared/agent-hook-listener-codex-approval-ownership.test.ts": 211, + "src/shared/agent-hook-listener-codex-interrupt.test.ts": 164, + "src/shared/agent-hook-listener-codex-main-agent.test.ts": 190, + "src/shared/agent-hook-listener-command-code-transcript.test.ts": 255, + "src/shared/agent-hook-listener-extraction-characterization.test.ts": 207, + "src/shared/agent-hook-listener-grok-completion.test.ts": 232, + "src/shared/agent-hook-listener-grok.test.ts": 232, + "src/shared/agent-hook-listener-hermes-codex-droid.test.ts": 203, + "src/shared/agent-hook-listener-interactive-prompts.test.ts": 179, + "src/shared/agent-hook-listener-jcode.test.ts": 214, + "src/shared/agent-hook-listener-omp-approval-ownership.test.ts": 200, + "src/shared/agent-hook-listener-opencode-reattribution.test.ts": 192, + "src/shared/agent-hook-listener-opencode-session-correlation.test.ts": 71, + "src/shared/agent-hook-listener-opencode-session-registry.test.ts": 71, + "src/shared/agent-hook-listener-opencode-tui-identity.test.ts": 180, + "src/shared/agent-hook-listener-pi-compatible.test.ts": 182, + "src/shared/agent-hook-listener-roster-retention.test.ts": 266, + "src/shared/agent-hook-listener-session-replacement.test.ts": 186, + "src/shared/agent-hook-listener-startless-child-lifecycle.test.ts": 169, + "src/shared/agent-hook-listener-transport.test.ts": 239, + "src/shared/agent-hook-listener/providers/dsh-events.test.ts": 161, + "src/shared/agent-hook-listener/providers/hermes-events.test.ts": 112, + "src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts": 115, + "src/shared/agent-hook-listener/providers/kiro-events.test.ts": 196, + "src/shared/agent-hook-listener/providers/muse-events.test.ts": 207, + "src/shared/agent-hook-listener/providers/zcode-events.test.ts": 197, + "src/shared/agent-hook-listener/reverse-file-region-scan.test.ts": 57, + "src/shared/agent-hook-listener/transcript-reader.test.ts": 94, + "src/shared/agent-hook-relay.test.ts": 54, + "src/shared/agent-hook-request-body-memory.test.ts": 92, + "src/shared/agent-hook-scrub-safe-env.test.ts": 58, + "src/shared/agent-hook-spool-read.test.ts": 62, + "src/shared/agent-hook-status-cache.test.ts": 80, + "src/shared/agent-hook-transport-interference.test.ts": 47, + "src/shared/agent-image-paste.test.ts": 57, + "src/shared/agent-kind.test.ts": 280, + "src/shared/agent-launch-operation.test.ts": 255, + "src/shared/agent-launch-preferences.test.ts": 45, + "src/shared/agent-launch-remote.test.ts": 43, + "src/shared/agent-lead-status-fold.test.ts": 55, + "src/shared/agent-main-agent-verdict.test.ts": 102, + "src/shared/agent-model-probe-spec.test.ts": 76, + "src/shared/agent-notification-id.test.ts": 48, + "src/shared/agent-process-presence-platform.test.ts": 55, + "src/shared/agent-process-presence-probe.test.ts": 69, + "src/shared/agent-process-recognition.test.ts": 204, + "src/shared/agent-prompt-injection.test.ts": 91, + "src/shared/agent-resume-argv-drop.test.ts": 134, + "src/shared/agent-resume-launch-command.test.ts": 195, + "src/shared/agent-row-conversation-name.test.ts": 70, + "src/shared/agent-scratch-worktrees.test.ts": 53, + "src/shared/agent-session-account-home.test.ts": 270, + "src/shared/agent-session-approval-blocked-path.test.ts": 50, + "src/shared/agent-session-approval-subject.test.ts": 228, + "src/shared/agent-session-attachments.test.ts": 46, + "src/shared/agent-session-availability.test.ts": 271, + "src/shared/agent-session-context-clear.test.ts": 362, + "src/shared/agent-session-conversation-name.test.ts": 64, + "src/shared/agent-session-definitive-refusal.test.ts": 277, + "src/shared/agent-session-failure-words.test.ts": 369, + "src/shared/agent-session-failure.test.ts": 269, + "src/shared/agent-session-journal-body-admission.test.ts": 303, + "src/shared/agent-session-journal-producer.test.ts": 47, + "src/shared/agent-session-journal-schemas.test.ts": 296, + "src/shared/agent-session-journal-submission-schema.test.ts": 239, + "src/shared/agent-session-lease-adjudication.test.ts": 62, + "src/shared/agent-session-legacy-handoff-lease.test.ts": 259, + "src/shared/agent-session-message-source.test.ts": 286, + "src/shared/agent-session-mutation-envelope.test.ts": 264, + "src/shared/agent-session-operation-ledger.test.ts": 284, + "src/shared/agent-session-option-catalog-grok.test.ts": 96, "src/shared/agent-session-option-catalog-omp.test.ts": 86, - "src/shared/agent-session-option-catalog.test.ts": 94, - "src/shared/agent-session-provider-handle.test.ts": 75, - "src/shared/agent-session-question-answer.test.ts": 38, - "src/shared/agent-session-restart-continuation.test.ts": 32, - "src/shared/agent-session-resume.test.ts": 53, - "src/shared/agent-session-thread-goal.test.ts": 60, - "src/shared/agent-session-turn-record.test.ts": 58, - "src/shared/agent-skill-sharing-contract.test.ts": 67, - "src/shared/agent-skill-sharing-gate.test.ts": 138, - "src/shared/agent-startup-plan-inputs.test.ts": 84, - "src/shared/agent-status-child-work-admission-activity.test.ts": 304, - "src/shared/agent-status-child-work-admission-collision.test.ts": 163, - "src/shared/agent-status-child-work-admission-parse.test.ts": 418, - "src/shared/agent-status-child-work-admission-sparse.test.ts": 228, - "src/shared/agent-status-child-work-admission.test.ts": 295, - "src/shared/agent-status-child-work-codec-boundary.test.ts": 740, - "src/shared/agent-status-child-work-legacy-golden.test.ts": 57, - "src/shared/agent-status-child-work-legality.test.ts": 234, - "src/shared/agent-status-child-work-lifetime.test.ts": 216, - "src/shared/agent-status-child-work-liveness.test.ts": 28, - "src/shared/agent-status-child-work-projection.test.ts": 54, - "src/shared/agent-status-child-work-reconciliation.test.ts": 439, - "src/shared/agent-status-child-work-view.test.ts": 128, - "src/shared/agent-status-child-work.test.ts": 192, - "src/shared/agent-status-legacy-adapter.test.ts": 38, - "src/shared/agent-status-legacy-ingress-ratchet.test.ts": 5709, - "src/shared/agent-status-observation.test.ts": 55, - "src/shared/agent-status-osc-pending-retention.test.ts": 96, - "src/shared/agent-status-osc-scan-budget.test.ts": 340, - "src/shared/agent-status-osc-split-frame-scan-budget.test.ts": 170, - "src/shared/agent-status-osc.test.ts": 65, - "src/shared/agent-status-run-alias-index.test.ts": 245, - "src/shared/agent-status-run-capability.test.ts": 42, + "src/shared/agent-session-option-catalog-opencode.test.ts": 79, + "src/shared/agent-session-option-catalog-removers.test.ts": 87, + "src/shared/agent-session-option-catalog.test.ts": 73, + "src/shared/agent-session-provider-context.test.ts": 267, + "src/shared/agent-session-provider-handle.test.ts": 100, + "src/shared/agent-session-question-answer.test.ts": 51, + "src/shared/agent-session-question-free-text.test.ts": 53, + "src/shared/agent-session-record-stored-form.test.ts": 264, + "src/shared/agent-session-refusal-notice.test.ts": 433, + "src/shared/agent-session-refusal-retry.test.ts": 53, + "src/shared/agent-session-registered-agents.test.ts": 233, + "src/shared/agent-session-restart-continuation.test.ts": 51, + "src/shared/agent-session-resume-cursor.test.ts": 144, + "src/shared/agent-session-resume.test.ts": 83, + "src/shared/agent-session-sign-in.test.ts": 269, + "src/shared/agent-session-status-snapshot-fold.test.ts": 62, + "src/shared/agent-session-stored-agent.test.ts": 250, + "src/shared/agent-session-thread-goal.test.ts": 56, + "src/shared/agent-session-turn-record.test.ts": 244, + "src/shared/agent-session-wire-refusals.test.ts": 250, + "src/shared/agent-session-write-failure.test.ts": 269, + "src/shared/agent-skill-sharing-contract.test.ts": 218, + "src/shared/agent-skill-sharing-gate.test.ts": 253, + "src/shared/agent-startup-plan-inputs-extra-agent-args.test.ts": 167, + "src/shared/agent-startup-plan-inputs.test.ts": 149, + "src/shared/agent-status-child-work-admission-activity.test.ts": 400, + "src/shared/agent-status-child-work-admission-collision.test.ts": 313, + "src/shared/agent-status-child-work-admission-parse.test.ts": 483, + "src/shared/agent-status-child-work-admission-sparse.test.ts": 334, + "src/shared/agent-status-child-work-admission.test.ts": 383, + "src/shared/agent-status-child-work-legacy-golden.test.ts": 77, + "src/shared/agent-status-child-work-legality.test.ts": 395, + "src/shared/agent-status-child-work-lifetime.test.ts": 464, + "src/shared/agent-status-child-work-liveness.test.ts": 47, + "src/shared/agent-status-child-work-projection.test.ts": 66, + "src/shared/agent-status-child-work-reconciliation.test.ts": 596, + "src/shared/agent-status-child-work-view-wire.test.ts": 49, + "src/shared/agent-status-child-work-view.test.ts": 339, + "src/shared/agent-status-child-work.test.ts": 311, + "src/shared/agent-status-legacy-adapter.test.ts": 65, + "src/shared/agent-status-observation.test.ts": 51, + "src/shared/agent-status-osc-pending-retention.test.ts": 126, + "src/shared/agent-status-osc-scan-budget.test.ts": 267, + "src/shared/agent-status-osc-split-frame-scan-budget.test.ts": 128, + "src/shared/agent-status-osc.test.ts": 98, + "src/shared/agent-status-run-alias-index.test.ts": 327, + "src/shared/agent-status-run-capability.test.ts": 87, "src/shared/agent-status-run.test.ts": 64, - "src/shared/agent-status-serving-readiness.test.ts": 32, - "src/shared/agent-status-store-bounds.test.ts": 2561, - "src/shared/agent-status-store-byte-budget.test.ts": 1188, - "src/shared/agent-status-store-in-place.test.ts": 40880, - "src/shared/agent-status-store-reopen.test.ts": 168, - "src/shared/agent-status-store-scaling.test.ts": 904, - "src/shared/agent-status-store.test.ts": 361, - "src/shared/agent-status-subject.test.ts": 174, - "src/shared/agent-status-transport-envelope.test.ts": 295, - "src/shared/agent-status-types.test.ts": 130, - "src/shared/agent-tab-title.test.ts": 76, - "src/shared/agent-terminal-status-equivalence.test.ts": 59, - "src/shared/agent-title-agy-gemini-collision.test.ts": 70, - "src/shared/agent-title-decoration.test.ts": 48, - "src/shared/agent-title-evidence.test.ts": 122, - "src/shared/agent-title-identity-characterization.test.ts": 73, - "src/shared/agent-tui-input-clear.test.ts": 39, - "src/shared/ai-vault-resume-command.test.ts": 68, - "src/shared/ai-vault-resume-preparation.test.ts": 49, - "src/shared/ai-vault-scan-error-message.test.ts": 26, - "src/shared/ai-vault-search-contract.test.ts": 138, + "src/shared/agent-status-serving-readiness.test.ts": 45, + "src/shared/agent-status-store-bounds.test.ts": 1432, + "src/shared/agent-status-store-byte-budget.test.ts": 1322, + "src/shared/agent-status-store-in-place.test.ts": 3761, + "src/shared/agent-status-store-parent.test.ts": 262, + "src/shared/agent-status-store-reopen.test.ts": 301, + "src/shared/agent-status-store-scaling.test.ts": 771, + "src/shared/agent-status-store.test.ts": 408, + "src/shared/agent-status-subject.test.ts": 317, + "src/shared/agent-status-transport-envelope.test.ts": 332, + "src/shared/agent-status-types.test.ts": 163, + "src/shared/agent-stop-display-status.test.ts": 51, + "src/shared/agent-tab-title.test.ts": 53, + "src/shared/agent-terminal-status-equivalence.test.ts": 40, + "src/shared/agent-title-agy-gemini-collision.test.ts": 84, + "src/shared/agent-title-decoration.test.ts": 46, + "src/shared/agent-title-evidence.test.ts": 114, + "src/shared/agent-title-identity-characterization.test.ts": 75, + "src/shared/agent-transcript-poll-scheduler.test.ts": 165, + "src/shared/agent-tui-input-clear.test.ts": 59, + "src/shared/ai-vault-resume-command.test.ts": 65, + "src/shared/ai-vault-resume-preparation.test.ts": 48, + "src/shared/ai-vault-scan-error-message.test.ts": 61, + "src/shared/ai-vault-search-agent-history.test.ts": 291, + "src/shared/ai-vault-search-contract.test.ts": 258, "src/shared/ai-vault-search-query-operators.test.ts": 70, - "src/shared/ai-vault-search-scope-contract.test.ts": 178, - "src/shared/ai-vault-search-settings.test.ts": 115, - "src/shared/ai-vault-session-depth.test.ts": 65, - "src/shared/ai-vault-session-filters.test.ts": 172, - "src/shared/ai-vault-types.test.ts": 52, - "src/shared/antigravity-source-control.test.ts": 165, - "src/shared/app-version.test.ts": 44, - "src/shared/artifact-sharing-gate.test.ts": 149, - "src/shared/automation-cron-dialect.test.ts": 50, - "src/shared/automation-cron-input-validation.test.ts": 78, - "src/shared/automation-execution-target.test.ts": 54, - "src/shared/automation-host-filter.test.ts": 53, - "src/shared/automation-legacy-list-partition.test.ts": 33, - "src/shared/automation-list-response.test.ts": 44, - "src/shared/automation-list-scope.test.ts": 51, - "src/shared/automation-owner-key.test.ts": 43, - "src/shared/automation-owner-precondition.test.ts": 75, - "src/shared/automation-run-identity.test.ts": 47, - "src/shared/automation-run-retention.test.ts": 45, - "src/shared/automation-schedule-drift.test.ts": 87, - "src/shared/automation-schedules.test.ts": 199, - "src/shared/automation-usage-summary.test.ts": 64, - "src/shared/base-ref-search-result.test.ts": 40, - "src/shared/binary-file-extensions.test.ts": 54, - "src/shared/bounded-map.test.ts": 68, + "src/shared/ai-vault-search-scope-contract.test.ts": 247, + "src/shared/ai-vault-search-settings.test.ts": 214, + "src/shared/ai-vault-search-status-fallback.test.ts": 278, + "src/shared/ai-vault-session-depth.test.ts": 66, + "src/shared/ai-vault-session-filters.test.ts": 173, + "src/shared/ai-vault-types.test.ts": 54, + "src/shared/antigravity-source-control.test.ts": 317, + "src/shared/antigravity-transcript-reference.test.ts": 59, + "src/shared/app-version.test.ts": 54, + "src/shared/artifact-sharing-gate.test.ts": 312, + "src/shared/automation-cron-dialect.test.ts": 77, + "src/shared/automation-cron-input-validation.test.ts": 66, + "src/shared/automation-execution-target.test.ts": 50, + "src/shared/automation-extra-agent-args.test.ts": 166, + "src/shared/automation-host-filter.test.ts": 68, + "src/shared/automation-legacy-list-partition.test.ts": 53, + "src/shared/automation-list-response.test.ts": 53, + "src/shared/automation-list-scope.test.ts": 96, + "src/shared/automation-owner-key.test.ts": 62, + "src/shared/automation-owner-precondition.test.ts": 80, + "src/shared/automation-run-identity.test.ts": 40, + "src/shared/automation-run-retention.test.ts": 87, + "src/shared/automation-schedule-drift.test.ts": 97, + "src/shared/automation-schedules.test.ts": 154, + "src/shared/automation-usage-summary.test.ts": 48, + "src/shared/background-task-roster.test.ts": 98, + "src/shared/base-ref-search-result.test.ts": 46, + "src/shared/binary-file-extensions.test.ts": 52, + "src/shared/bounded-map.test.ts": 73, "src/shared/branch-name-from-work.test.ts": 59, - "src/shared/branch-prefix.test.ts": 55, - "src/shared/browser-client-automation-protocol.test.ts": 145, - "src/shared/browser-client-host-id-argument.test.ts": 51, - "src/shared/browser-client-host-protocol.test.ts": 255, - "src/shared/browser-client-host-reconciliation-protocol.test.ts": 81, - "src/shared/browser-client-hosting-eligibility.test.ts": 39, - "src/shared/browser-cookie-import-sources.test.ts": 31, - "src/shared/browser-grab-types.test.ts": 49, - "src/shared/browser-network-capabilities.test.ts": 28, - "src/shared/browser-network-tunnel-protocol.test.ts": 86, - "src/shared/browser-network-tunnel-stream-framing.test.ts": 1565, - "src/shared/browser-screencast-protocol.test.ts": 48, - "src/shared/browser-url.test.ts": 128, - "src/shared/bundled-ripgrep.test.ts": 24, - "src/shared/cheap-process-table-snapshot.test.ts": 55, - "src/shared/check-job-log-byte-cap.test.ts": 77, - "src/shared/check-job-log-retention.test.ts": 186, - "src/shared/check-job-log-tail-slice.test.ts": 55, - "src/packages/process-host/src/bounded-output-sink.test.ts": 48, - "src/packages/process-host/src/close-process-registry.test.ts": 40, - "src/packages/process-host/src/posix-lsof-probe.test.ts": 4289, - "src/packages/process-host/src/process-tree-termination.test.ts": 68, - "src/packages/process-host/src/retryable-process-exit-proof.test.ts": 38, - "src/packages/process-host/src/run-process-termination-failure.test.ts": 49, - "src/packages/process-host/src/run-process.test.ts": 7245, - "src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts": 57, - "src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts": 42, - "src/packages/process-host/src/windows-command-line.test.ts": 55, - "src/shared/__tests__/process-host/windows-command-line.win32.test.ts": 39, - "src/shared/child-process/windows-console-visibility.test.ts": 3436, - "src/shared/claimed-agent-pty-owner.test.ts": 94, - "src/shared/claude-agent-teams-tmux-compat.test.ts": 57, - "src/shared/claude-background-task-kind.test.ts": 26, - "src/shared/claude-background-task-status.test.ts": 168, - "src/shared/claude-model-list-probe.test.ts": 50, - "src/shared/claude-statusline-rate-limits.test.ts": 54, - "src/shared/claude-subagent-roster.test.ts": 65, - "src/shared/claude-subagent-row-lifecycle.test.ts": 193, - "src/shared/cli-argument-boundary.test.ts": 35, - "src/shared/cli-runtime-pairing-boundary.test.ts": 201, - "src/shared/cli-workspace-provenance.test.ts": 35, - "src/shared/client-environment-info.test.ts": 55, - "src/shared/clipboard-image.test.ts": 36, - "src/shared/clipboard-text.test.ts": 75, - "src/shared/closed-terminal-tab-tombstones.test.ts": 125, - "src/shared/cloud-service-url.test.ts": 38, - "src/shared/codex-auth-errors.test.ts": 51, - "src/shared/codex-pet-sprite-defaults.test.ts": 62, - "src/shared/codex-reset-credit-attempt-ledger.test.ts": 129, - "src/shared/codex-reset-credit-scope.test.ts": 48, - "src/shared/codex-startup-delivery.test.ts": 43, - "src/shared/codex-subagent-poll-scheduler.test.ts": 54, - "src/shared/codex-subagent-rollout-lifecycle.test.ts": 109, - "src/shared/codex-subagent-roster.test.ts": 75, - "src/shared/codex-subagent-transcript.test.ts": 72, - "src/shared/combined-diff-file-tree-width.test.ts": 49, - "src/shared/command-code-output-retention.test.ts": 64, - "src/shared/command-code-output-status.test.ts": 214, - "src/shared/command-code-turn-boundary.test.ts": 48, - "src/shared/command-environment.test.ts": 69, - "src/shared/command-token-scanner.test.ts": 48, - "src/shared/commit-message-agent-spec.test.ts": 122, - "src/shared/commit-message-generation.test.ts": 57, - "src/shared/commit-message-omp.test.ts": 239, - "src/shared/commit-message-plan.test.ts": 51, - "src/shared/commit-message-prompt.test.ts": 60, - "src/shared/computer-awake-mode.test.ts": 36, - "src/shared/computer-use-error-recovery.test.ts": 50, - "src/shared/computer-use-key-spec.test.ts": 27, - "src/shared/constants.test.ts": 193, - "src/shared/contextual-tours.test.ts": 63, - "src/shared/crash-reporting.test.ts": 45, - "src/shared/cross-platform-path-guards.test.ts": 1653, - "src/shared/cross-platform-path.test.ts": 47, - "src/shared/daemon-adoption-telemetry.test.ts": 168, - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 152, - "src/shared/doc-preview-file-access.test.ts": 80, - "src/shared/doc-preview-scheme.test.ts": 46, - "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 98, - "src/shared/draft-paste-ready-scanner.test.ts": 46, - "src/shared/emoji-shortcode-catalog.lazy.test.ts": 116, - "src/shared/emulator-keyboard-frame.test.ts": 53, - "src/shared/emulator-touch-frame.test.ts": 36, - "src/shared/ephemeral-setup-terminal-worktree-id.test.ts": 50, - "src/shared/ephemeral-vm-recipe-checkout-mode.test.ts": 130, - "src/shared/ephemeral-vm-recipe-doctor.test.ts": 47, - "src/shared/ephemeral-vm-recipe-output-capture.test.ts": 223, - "src/shared/ephemeral-vm-recipe-process.test.ts": 237, - "src/shared/ephemeral-vm-recipe-repo-url.test.ts": 51, - "src/shared/ephemeral-vm-recipes.test.ts": 122, - "src/shared/ephemeral-vm-runtime-feature-sorting.test.ts": 285, - "src/shared/ephemeral-vm-runtime-store-rollback.test.ts": 661, - "src/shared/ephemeral-vm-runtime-store.test.ts": 213, - "src/shared/event-loop-yield.test.ts": 68, - "src/shared/execution-host-registry.test.ts": 70, - "src/shared/execution-host.test.ts": 66, - "src/shared/export-let-function-initializer-ban.test.ts": 3945, - "src/shared/external-automation-jobs-file.test.ts": 95, - "src/shared/external-worktree-inbox.test.ts": 35, - "src/shared/feature-education-telemetry.test.ts": 38, - "src/shared/feature-interactions.test.ts": 2141, - "src/shared/feature-tips.test.ts": 68, - "src/shared/feature-wall-tour-depth.test.ts": 27, - "src/shared/feedback-image-limits.test.ts": 49, - "src/shared/file-link-location.test.ts": 51, - "src/shared/file-name-sort.test.ts": 48, - "src/shared/filesystem-directory-listing-limit.test.ts": 53, - "src/shared/fish-binary-requirement.test.ts": 45, - "src/shared/folder-workspace-execution-host.test.ts": 79, - "src/shared/folder-workspace-worktree.test.ts": 46, - "src/shared/folder-workspaces.test.ts": 33, - "src/shared/foreground-process-ancestry-parity.test.ts": 379, - "src/shared/foreground-process-selection.test.ts": 96, - "src/shared/foreground-wrapper-agent.test.ts": 51, - "src/shared/generated-code-path.test.ts": 46, - "src/shared/git-binary-compatibility.test.ts": 52, - "src/shared/git-branch-cleanup.test.ts": 62, - "src/shared/git-branch-compare-head.test.ts": 30, - "src/shared/git-branch-line-total-soft-deadline.test.ts": 3579, - "src/shared/git-branch-line-total.test.ts": 60, - "src/shared/git-capability-cache.test.ts": 58, - "src/shared/git-check-ignore-stdio.test.ts": 54, - "src/shared/git-clone-failure-message.test.ts": 74, - "src/shared/git-config-snapshot-runner.test.ts": 67, - "src/shared/git-configured-branch-target.test.ts": 60, - "src/shared/git-cquoted-path.test.ts": 29, - "src/shared/git-diff-transport-budget.test.ts": 2035, - "src/shared/git-discard-path-safety.test.ts": 40, - "src/shared/git-exec-mutation.test.ts": 53, - "src/shared/git-fetch-head-capability.test.ts": 39, - "src/shared/git-fetch-head-lock-key-derivation.test.ts": 133, - "src/shared/git-fetch-head-lock.test.ts": 169, - "src/shared/git-fork-sync.test.ts": 54, - "src/shared/git-history-graph.test.ts": 95, - "src/shared/git-history-message-allocation.test.ts": 45, - "src/shared/git-history-ref-display.test.ts": 65, - "src/shared/git-history.test.ts": 68, - "src/shared/git-merge-tree-capability.test.ts": 31, - "src/shared/git-metadata-path.test.ts": 63, - "src/shared/git-push-target-validation.test.ts": 44, - "src/shared/git-remote-error.test.ts": 73, - "src/shared/git-remote-identity.test.ts": 56, - "src/shared/git-remote-url-index.test.ts": 53, - "src/shared/git-rev-list-output.test.ts": 43, - "src/shared/git-status-branch-line-total-cache.test.ts": 55, - "src/shared/git-status-conflict-entries.test.ts": 33, - "src/shared/git-status-limit.test.ts": 37, - "src/shared/git-status-line-stat-inputs.test.ts": 32, - "src/shared/git-status-line-stats-cache.test.ts": 67, - "src/shared/git-status-upstream-ref.test.ts": 59, - "src/shared/git-tracked-pathspecs.test.ts": 207, - "src/shared/git-uncommitted-line-stats.test.ts": 85, - "src/shared/git-upstream-status.test.ts": 33, - "src/shared/git-worktree-operation-lock.test.ts": 32, - "src/shared/github/account-binding.test.ts": 41, - "src/shared/github/api-availability.test.ts": 38, - "src/shared/github/owner-slug.test.ts": 40, - "src/shared/github/project-identity.test.ts": 25, - "src/shared/github/project-ref-input.test.ts": 39, - "src/shared/github/project-roadmap-timeline.test.ts": 77, - "src/shared/github/pull-request-auto-merge-availability.test.ts": 64, - "src/shared/github/pull-request-for-branch-outcome.test.ts": 45, - "src/shared/github/pull-request-merge-methods.test.ts": 50, - "src/shared/github/repository-identity-key.test.ts": 49, - "src/shared/github/work-items-query-bounds.test.ts": 58, - "src/shared/gitlab-job-log-excerpt.test.ts": 91, - "src/shared/gitlab-job-trace-check-details.test.ts": 32, - "src/shared/gitlab-pipeline-checks.test.ts": 34, - "src/shared/gitlab-projects.test.ts": 40, - "src/shared/grok-model-list-probe.test.ts": 49, - "src/shared/grok-session-paths.test.ts": 87, - "src/packages/process-host/src/growing-byte-buffer.test.ts": 96, - "src/shared/handled-wire-discriminant.test.ts": 36, - "src/shared/harness-injected-user-turns.test.ts": 42, - "src/shared/hermes-run-ref-retention.test.ts": 29, - "src/shared/hermes-session-run-index.test.ts": 692, - "src/shared/hermes-startup-query.windows.test.ts": 41, - "src/shared/hook-command-source-policy.test.ts": 52, - "src/shared/host-balanced-listing-scaling.test.ts": 56, - "src/shared/host-display-resolution.test.ts": 33, - "src/shared/host-setting-overrides.test.ts": 37, + "src/shared/branch-prefix.test.ts": 63, + "src/shared/browser-annotation-viewport-lifecycle.test.ts": 186, + "src/shared/browser-client-automation-protocol.test.ts": 240, + "src/shared/browser-client-host-id-argument.test.ts": 52, + "src/shared/browser-client-host-protocol.test.ts": 320, + "src/shared/browser-client-host-reconciliation-protocol.test.ts": 254, + "src/shared/browser-client-hosting-eligibility.test.ts": 67, + "src/shared/browser-cookie-import-sources.test.ts": 57, + "src/shared/browser-grab-types.test.ts": 56, + "src/shared/browser-network-tunnel-protocol.test.ts": 45, + "src/shared/browser-network-tunnel-stream-framing.test.ts": 1622, + "src/shared/browser-screencast-protocol.test.ts": 108, + "src/shared/browser-url.test.ts": 308, + "src/shared/bundled-ripgrep.test.ts": 37, + "src/shared/cheap-process-table-snapshot.test.ts": 77, + "src/shared/check-job-log-byte-cap.test.ts": 94, + "src/shared/check-job-log-retention.test.ts": 222, + "src/shared/check-job-log-tail-slice.test.ts": 71, + "src/shared/claimed-agent-pty-owner.test.ts": 71, + "src/shared/claude-agent-teams-tmux-compat.test.ts": 62, + "src/shared/claude-background-task-kind.test.ts": 42, + "src/shared/claude-background-task-status.test.ts": 260, + "src/shared/claude-model-list-probe.test.ts": 57, + "src/shared/claude-owed-notification-resource-contract.test.ts": 183, + "src/shared/claude-profile-activation-contract.test.ts": 68, + "src/shared/claude-shell-function.test.ts": 196, + "src/shared/claude-sign-in-link.test.ts": 47, + "src/shared/claude-statusline-rate-limits.test.ts": 47, + "src/shared/claude-subagent-roster.test.ts": 111, + "src/shared/claude-subagent-row-lifecycle.test.ts": 205, + "src/shared/claude-terminal-interrupt.test.ts": 54, + "src/shared/cli-argument-boundary.test.ts": 54, + "src/shared/cli-workspace-provenance.test.ts": 50, + "src/shared/client-environment-info.test.ts": 42, + "src/shared/clipboard-image.test.ts": 40, + "src/shared/clipboard-text.test.ts": 86, + "src/shared/closed-terminal-tab-tombstones.test.ts": 242, + "src/shared/cloud-service-url.test.ts": 45, + "src/shared/coalescing-keyed-runner.test.ts": 112, + "src/shared/codebuddy-agent.test.ts": 234, + "src/shared/codex-auth-errors.test.ts": 55, + "src/shared/codex-composer-ready-scanner.test.ts": 97, + "src/shared/codex-pet-sprite-defaults.test.ts": 70, + "src/shared/codex-reset-credit-attempt-ledger.test.ts": 213, + "src/shared/codex-reset-credit-scope.test.ts": 54, + "src/shared/codex-shared-server-command.test.ts": 65, + "src/shared/codex-startup-delivery.test.ts": 85, + "src/shared/codex-status-transcript-line.test.ts": 83, + "src/shared/codex-subagent-poll-scheduler.test.ts": 57, + "src/shared/codex-subagent-rollout-lifecycle.test.ts": 172, + "src/shared/codex-subagent-roster.test.ts": 60, + "src/shared/codex-subagent-transcript.test.ts": 100, + "src/shared/codex-turn-transcript.test.ts": 46, + "src/shared/combined-diff-file-tree-width.test.ts": 46, + "src/shared/command-code-output-retention.test.ts": 109, + "src/shared/command-code-output-status.test.ts": 205, + "src/shared/command-code-turn-boundary.test.ts": 42, + "src/shared/command-environment.test.ts": 86, + "src/shared/command-token-scanner.test.ts": 54, + "src/shared/commit-message-agent-spec.test.ts": 118, + "src/shared/commit-message-generation.test.ts": 62, + "src/shared/commit-message-omp.test.ts": 390, + "src/shared/commit-message-plan.test.ts": 117, + "src/shared/commit-message-prompt.test.ts": 87, + "src/shared/computer-awake-mode.test.ts": 43, + "src/shared/computer-use-error-recovery.test.ts": 59, + "src/shared/computer-use-key-spec.test.ts": 59, + "src/shared/constants.test.ts": 282, + "src/shared/contextual-tours.test.ts": 67, + "src/shared/conversation-name-action.test.ts": 263, + "src/shared/conversation-name-generation.test.ts": 50, + "src/shared/crash-reporting.test.ts": 80, + "src/shared/credit-count-format.test.ts": 58, + "src/shared/cross-platform-path-guards.test.ts": 943, + "src/shared/cross-platform-path.test.ts": 55, + "src/shared/currency-format.test.ts": 47, + "src/shared/daemon-adoption-telemetry.test.ts": 349, + "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 279, + "src/shared/doc-preview-file-access.test.ts": 93, + "src/shared/doc-preview-scheme.test.ts": 44, + "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 75, + "src/shared/draft-paste-ready-scanner.test.ts": 92, + "src/shared/dsb-headless-command.test.ts": 55, + "src/shared/dsb-native-one-shot-recognition.test.ts": 89, + "src/shared/dsb-terminal-title.test.ts": 105, + "src/shared/dsh-launch-command.test.ts": 115, + "src/shared/dsh-real-cli.test.ts": 73, + "src/shared/dsh-startup.test.ts": 140, + "src/shared/dsh-terminal-title.test.ts": 90, + "src/shared/emoji-shortcode-catalog.lazy.test.ts": 79, + "src/shared/emulator-keyboard-frame.test.ts": 61, + "src/shared/emulator-touch-frame.test.ts": 45, + "src/shared/ephemeral-setup-terminal-worktree-id.test.ts": 55, + "src/shared/ephemeral-vm-recipe-checkout-mode.test.ts": 261, + "src/shared/ephemeral-vm-recipe-doctor.test.ts": 51, + "src/shared/ephemeral-vm-recipe-output-capture.test.ts": 260, + "src/shared/ephemeral-vm-recipe-process.test.ts": 180, + "src/shared/ephemeral-vm-recipe-repo-url.test.ts": 52, + "src/shared/ephemeral-vm-recipes.test.ts": 291, + "src/shared/ephemeral-vm-runtime-feature-sorting.test.ts": 350, + "src/shared/ephemeral-vm-runtime-store-rollback.test.ts": 548, + "src/shared/ephemeral-vm-runtime-store.test.ts": 314, + "src/shared/event-loop-yield.test.ts": 69, + "src/shared/execution-host-registry-shared-control-connect.test.ts": 82, + "src/shared/execution-host-registry.test.ts": 91, + "src/shared/execution-host.test.ts": 68, + "src/shared/export-let-function-initializer-ban.test.ts": 2472, + "src/shared/external-automation-jobs-file.test.ts": 73, + "src/shared/external-worktree-inbox.test.ts": 71, + "src/shared/feature-education-telemetry.test.ts": 47, + "src/shared/feature-interactions.test.ts": 44, + "src/shared/feature-tips.test.ts": 66, + "src/shared/feature-wall-tour-depth.test.ts": 44, + "src/shared/feedback-image-limits.test.ts": 47, + "src/shared/file-explorer-display-root.test.ts": 53, + "src/shared/file-inventory-budget.test.ts": 54, + "src/shared/file-link-location.test.ts": 56, + "src/shared/file-name-sort.test.ts": 51, + "src/shared/fish-binary-requirement.test.ts": 46, + "src/shared/folder-workspace-execution-host.test.ts": 84, + "src/shared/folder-workspace-worktree.test.ts": 89, + "src/shared/folder-workspaces.test.ts": 82, + "src/shared/foreground-process-ancestry-parity.test.ts": 259, + "src/shared/foreground-process-selection.test.ts": 85, + "src/shared/foreground-wrapper-agent.test.ts": 87, + "src/shared/freebuff-launch.test.ts": 147, + "src/shared/freebuff-startup.test.ts": 120, + "src/shared/generated-code-path.test.ts": 53, + "src/shared/git-admission-node-compatibility.test.ts": 73, + "src/shared/git-binary-compatibility.test.ts": 123, + "src/shared/git-blob-absence.test.ts": 63, + "src/shared/git-branch-cleanup.test.ts": 73, + "src/shared/git-branch-compare-head.test.ts": 47, + "src/shared/git-branch-line-total-soft-deadline.test.ts": 3606, + "src/shared/git-branch-line-total.test.ts": 102, + "src/shared/git-capability-cache.test.ts": 59, + "src/shared/git-change-list.test.ts": 115, + "src/shared/git-check-ignore-stdio.test.ts": 47, + "src/shared/git-clone-failure-message.test.ts": 55, + "src/shared/git-config-snapshot-runner.test.ts": 68, + "src/shared/git-configured-branch-target.test.ts": 55, + "src/shared/git-cquoted-path.test.ts": 45, + "src/shared/git-diff-transport-budget.test.ts": 483, + "src/shared/git-discard-path-safety.test.ts": 68, + "src/shared/git-exec-mutation.test.ts": 40, + "src/shared/git-fetch-head-capability.test.ts": 45, + "src/shared/git-fetch-head-lock-key-derivation.test.ts": 123, + "src/shared/git-fetch-head-lock.test.ts": 162, + "src/shared/git-fork-sync.test.ts": 85, + "src/shared/git-history-graph.test.ts": 70, + "src/shared/git-history-message-allocation.test.ts": 49, + "src/shared/git-history-real-repository.test.ts": 465, + "src/shared/git-history-ref-display.test.ts": 63, + "src/shared/git-history.test.ts": 69, + "src/shared/git-lock-contention.test.ts": 48, + "src/shared/git-merge-tree-capability.test.ts": 53, + "src/shared/git-metadata-path.test.ts": 70, + "src/shared/git-push-target-validation.test.ts": 50, + "src/shared/git-remote-error.test.ts": 74, + "src/shared/git-remote-identity.test.ts": 61, + "src/shared/git-remote-url-index.test.ts": 58, + "src/shared/git-resolution-config-snapshot.test.ts": 73, + "src/shared/git-rev-list-output.test.ts": 58, + "src/shared/git-ssh-policy-env.test.ts": 204, + "src/shared/git-status-branch-line-total-cache.test.ts": 83, + "src/shared/git-status-conflict-entries.test.ts": 60, + "src/shared/git-status-limit.test.ts": 46, + "src/shared/git-status-line-stat-inputs.test.ts": 51, + "src/shared/git-status-line-stats-cache.test.ts": 82, + "src/shared/git-status-read-lease-expiry.test.ts": 79, + "src/shared/git-status-upstream-ref.test.ts": 58, + "src/shared/git-tracked-pathspecs.test.ts": 163, + "src/shared/git-uncommitted-line-stats.test.ts": 94, + "src/shared/git-upstream-status.test.ts": 53, + "src/shared/git-worktree-admin.test.ts": 130, + "src/shared/git-worktree-operation-lock.test.ts": 59, + "src/shared/github/account-binding.test.ts": 65, + "src/shared/github/api-availability.test.ts": 54, + "src/shared/github/owner-slug.test.ts": 49, + "src/shared/github/project-board-columns.test.ts": 65, + "src/shared/github/project-identity.test.ts": 46, + "src/shared/github/project-ref-input.test.ts": 52, + "src/shared/github/project-roadmap-timeline.test.ts": 70, + "src/shared/github/pull-request-auto-merge-availability.test.ts": 59, + "src/shared/github/pull-request-for-branch-outcome.test.ts": 46, + "src/shared/github/pull-request-merge-methods.test.ts": 49, + "src/shared/github/repository-identity-key.test.ts": 46, + "src/shared/gitlab-job-log-excerpt.test.ts": 80, + "src/shared/gitlab-job-trace-check-details.test.ts": 67, + "src/shared/gitlab-pipeline-checks.test.ts": 53, + "src/shared/gitlab-projects.test.ts": 50, + "src/shared/grok-model-list-probe.test.ts": 69, + "src/shared/grok-session-paths.test.ts": 93, + "src/shared/handled-wire-discriminant.test.ts": 48, + "src/shared/harness-injected-user-turns.test.ts": 64, + "src/shared/hermes-run-ref-retention.test.ts": 66, + "src/shared/hermes-session-run-index.test.ts": 293, + "src/shared/hermes-startup-query.windows.test.ts": 49, + "src/shared/home-or-filesystem-root.test.ts": 50, + "src/shared/hook-command-source-policy.test.ts": 43, + "src/shared/host-authority.test.ts": 66, + "src/shared/host-balanced-listing-scaling.test.ts": 74, + "src/shared/host-display-resolution.test.ts": 59, + "src/shared/host-operation-ownership.test.ts": 758, + "src/shared/host-setting-overrides.test.ts": 53, "src/shared/hosted-review-creation-providers.test.ts": 52, - "src/shared/hosted-review-github.test.ts": 48, - "src/shared/hosted-review-ready-capabilities.test.ts": 37, - "src/shared/hosted-review-refs.test.ts": 109, - "src/shared/image-data-uri.test.ts": 41, - "src/shared/image-paste-following-text.test.ts": 68, - "src/shared/in-flight-promise-dedupe.test.ts": 61, - "src/shared/issue-link-input.test.ts": 60, - "src/shared/jira-issue-url.test.ts": 57, - "src/shared/json-text-structure-limit.test.ts": 40, - "src/shared/keybindings-conflicts.test.ts": 94, - "src/shared/keybindings-default-bindings.test.ts": 109, - "src/shared/keybindings-digit-index.test.ts": 110, - "src/shared/keybindings-double-tap.test.ts": 85, - "src/shared/keybindings-keyboard-layout.test.ts": 56, - "src/shared/keybindings-mouse.test.ts": 44, - "src/shared/keybindings-parse-cache.test.ts": 142, - "src/shared/keybindings-parsing.test.ts": 47, - "src/shared/keybindings-terminal-context.test.ts": 49, - "src/shared/keybindings-unassigned-actions.test.ts": 61, - "src/shared/linear/inline-media.test.ts": 36, - "src/shared/linear/issue-attribute-filter.test.ts": 58, - "src/shared/linear/issue-view-resume-state.test.ts": 67, - "src/shared/linear/links.test.ts": 74, - "src/shared/linear/workspace-types.test.ts": 25, - "src/shared/linux-proc-port-scan-limits.test.ts": 42, - "src/shared/local-account-runtime.test.ts": 93, - "src/shared/local-build-compatibility.test.ts": 34, - "src/shared/localhost-worktree-labels.test.ts": 52, - "src/shared/loose-ref-count.test.ts": 358, - "src/shared/machine-name.test.ts": 35, - "src/shared/macos-symbolic-hotkeys.test.ts": 100, - "src/shared/main-agent-status-parity.test.ts": 206, - "src/shared/managed-agent-command-token.test.ts": 26, - "src/shared/manual-repo-order.test.ts": 36, - "src/shared/map-settled-with-concurrency.test.ts": 60, - "src/shared/map-with-concurrency.test.ts": 82, - "src/shared/markdown-document-listing-limits.test.ts": 47, - "src/shared/markdown-toc-panel-width.test.ts": 53, - "src/shared/mcp-config.test.ts": 128, - "src/shared/mobile-e2ee-v2-contract.test.ts": 41, - "src/shared/mobile-e2ee-v2-framing.test.ts": 55, - "src/shared/mobile-file-directory-limit.test.ts": 49, - "src/shared/mobile-markdown-document.test.ts": 40, - "src/shared/mobile-pairing-connection-mode.test.ts": 49, - "src/shared/mobile-pairing-custom-address.test.ts": 57, - "src/shared/mobile-push-contract.test.ts": 44, - "src/shared/mobile-relay-close-codes.test.ts": 49, - "src/shared/mobile-relay-mint-failure.test.ts": 33, - "src/shared/mobile-relay-pairing-offer.test.ts": 151, - "src/shared/mobile-relay-phone-protocol.test.ts": 109, - "src/shared/mobile-web-bundle/bundle-range-rpc-contract.test.ts": 108, - "src/shared/mobile-web-bundle/bundle-rpc-contract.test.ts": 153, - "src/shared/mobile-web-bundle/manifest-contract.test.ts": 204, - "src/shared/model-id-label.test.ts": 45, - "src/shared/modifier-double-tap-detector.test.ts": 47, - "src/shared/mouse-shortcut-input.test.ts": 49, + "src/shared/hosted-review-github.test.ts": 98, + "src/shared/hosted-review-refs.test.ts": 144, + "src/shared/image-data-uri.test.ts": 57, + "src/shared/image-paste-following-text.test.ts": 47, + "src/shared/import-copy-name.test.ts": 56, + "src/shared/in-flight-promise-dedupe.test.ts": 80, + "src/shared/issue-link-input.test.ts": 65, + "src/shared/jcode-runtime-dir.test.ts": 54, + "src/shared/jcode-session-files.test.ts": 58, + "src/shared/jcode-terminal-title.test.ts": 50, + "src/shared/jira-issue-url.test.ts": 47, + "src/shared/jira-search-input-jql.test.ts": 71, + "src/shared/json-text-structure-limit.test.ts": 70, + "src/shared/json-token-reader.test.ts": 309, + "src/shared/keybindings-conflicts.test.ts": 97, + "src/shared/keybindings-default-bindings.test.ts": 115, + "src/shared/keybindings-digit-index.test.ts": 96, + "src/shared/keybindings-double-tap.test.ts": 83, + "src/shared/keybindings-keyboard-layout.test.ts": 72, + "src/shared/keybindings-parse-cache.test.ts": 70, + "src/shared/keybindings-parsing.test.ts": 91, + "src/shared/keybindings-terminal-context.test.ts": 76, + "src/shared/keybindings-unassigned-actions.test.ts": 85, + "src/shared/kiro-home.test.ts": 46, + "src/shared/linear/inline-media.test.ts": 50, + "src/shared/linear/issue-attribute-filter.test.ts": 55, + "src/shared/linear/issue-view-resume-state.test.ts": 182, + "src/shared/linear/links.test.ts": 68, + "src/shared/linear/workspace-types.test.ts": 59, + "src/shared/linux-proc-port-scan-limits.test.ts": 53, + "src/shared/local-account-runtime.test.ts": 279, + "src/shared/local-build-compatibility.test.ts": 58, + "src/shared/local-windows-terminal-runtime.test.ts": 52, + "src/shared/localhost-worktree-labels.test.ts": 58, + "src/shared/loose-ref-count.test.ts": 139, + "src/shared/machine-name.test.ts": 44, + "src/shared/macos-symbolic-hotkeys.test.ts": 75, + "src/shared/main-agent-status-parity.test.ts": 224, + "src/shared/managed-agent-command-token.test.ts": 53, + "src/shared/managed-orcad-execution-host.test.ts": 85, + "src/shared/managed-orcad-ssh-owner.test.ts": 56, + "src/shared/manual-repo-order.test.ts": 56, + "src/shared/map-settled-with-concurrency.test.ts": 54, + "src/shared/map-with-concurrency.test.ts": 106, + "src/shared/markdown-document-listing-limits.test.ts": 45, + "src/shared/markdown-toc-panel-width.test.ts": 48, + "src/shared/mcp-config.test.ts": 95, + "src/shared/mobile-e2ee-v2-contract.test.ts": 59, + "src/shared/mobile-e2ee-v2-framing.test.ts": 65, + "src/shared/mobile-file-directory-limit.test.ts": 48, + "src/shared/mobile-markdown-document.test.ts": 61, + "src/shared/mobile-pairing-connection-mode.test.ts": 59, + "src/shared/mobile-pairing-custom-address.test.ts": 62, + "src/shared/mobile-push-contract.test.ts": 48, + "src/shared/mobile-relay-close-codes.test.ts": 42, + "src/shared/mobile-relay-mint-failure.test.ts": 43, + "src/shared/mobile-relay-pairing-offer.test.ts": 246, + "src/shared/mobile-relay-phone-protocol.test.ts": 223, + "src/shared/mobile-web-bundle/bundle-range-rpc-contract.test.ts": 241, + "src/shared/mobile-web-bundle/bundle-rpc-contract.test.ts": 254, + "src/shared/mobile-web-bundle/manifest-contract.test.ts": 284, + "src/shared/model-id-label.test.ts": 46, + "src/shared/modifier-double-tap-detector.test.ts": 62, "src/shared/muse-session-log.test.ts": 63, - "src/shared/native-chat-activity-tail-line.test.ts": 155, - "src/shared/native-chat-agent-profiles.test.ts": 68, - "src/shared/native-chat-agent-support.test.ts": 52, - "src/shared/native-chat-ask-fifo.test.ts": 386, - "src/shared/native-chat-ask-row.test.ts": 41, - "src/shared/native-chat-ask.test.ts": 41, - "src/shared/native-chat-background-task-row.test.ts": 61, - "src/shared/native-chat-command-envelope.test.ts": 45, - "src/shared/native-chat-edit-normalize.test.ts": 189, - "src/shared/native-chat-href-routing.test.ts": 32, - "src/shared/native-chat-image-transcript-markers.test.ts": 45, - "src/shared/native-chat-session-option-commands.test.ts": 61, - "src/shared/native-chat-session-option-defaults.test.ts": 59, - "src/shared/native-chat-session-option-snapshot.test.ts": 107, - "src/shared/native-chat-session-option-state.test.ts": 31, - "src/shared/native-chat-shell-environment.test.ts": 39, - "src/shared/native-chat-slash-commands.omp.test.ts": 152, - "src/shared/native-chat-slash-commands.test.ts": 47, - "src/shared/native-chat-stream-unsubscribe.test.ts": 52, - "src/shared/native-chat-streaming.test.ts": 53, - "src/shared/native-chat-subagent-summary.test.ts": 42, - "src/shared/native-chat-task-list.test.ts": 40, - "src/shared/native-chat-tool-activity.test.ts": 30, - "src/shared/native-chat-tool-attribution-allocation.test.ts": 79, - "src/shared/native-chat-tool-icon.test.ts": 70, - "src/shared/native-chat-tool-identity.test.ts": 150, - "src/shared/native-chat-tool-pair-limit.test.ts": 119, - "src/shared/native-chat-tool-pairing.test.ts": 56, - "src/shared/native-chat-tool-preview-prefix.test.ts": 60, - "src/shared/native-chat-tool-preview-scan.test.ts": 40, - "src/shared/native-chat-tool-run-outcome.test.ts": 59, - "src/shared/native-chat-tool-run-sentence.test.ts": 66, - "src/shared/native-chat-tool-summary.test.ts": 55, - "src/shared/native-chat-transcript-retention.test.ts": 43, - "src/shared/native-chat-turn-activity.test.ts": 74, - "src/shared/native-chat-turn-fold.test.ts": 29, - "src/shared/native-chat-turn-status.test.ts": 65, - "src/shared/native-chat-types.test.ts": 46, - "src/shared/native-chat-unverifiable-turn-status.test.ts": 44, - "src/shared/native-file-drop.test.ts": 73, - "src/shared/nested-repo-telemetry-schema.test.ts": 152, + "src/shared/native-chat-activity-tail-line.test.ts": 268, + "src/shared/native-chat-agent-profiles.test.ts": 58, + "src/shared/native-chat-agent-support.test.ts": 54, + "src/shared/native-chat-appearance-settings.test.ts": 56, + "src/shared/native-chat-ask-fifo.test.ts": 218, + "src/shared/native-chat-ask-row.test.ts": 62, + "src/shared/native-chat-ask.test.ts": 69, + "src/shared/native-chat-background-task-row.test.ts": 60, + "src/shared/native-chat-command-envelope.test.ts": 49, + "src/shared/native-chat-command-output.test.ts": 55, + "src/shared/native-chat-context-usage.test.ts": 52, + "src/shared/native-chat-cut-turn-notice.test.ts": 342, + "src/shared/native-chat-edit-normalize.test.ts": 326, + "src/shared/native-chat-href-routing.test.ts": 58, + "src/shared/native-chat-image-transcript-markers.test.ts": 85, + "src/shared/native-chat-live-line.test.ts": 74, + "src/shared/native-chat-merge-pairs.test.ts": 343, + "src/shared/native-chat-orca-stop-cut.test.ts": 273, + "src/shared/native-chat-provider-frame-summary.test.ts": 49, + "src/shared/native-chat-provider-retry-runs.test.ts": 340, + "src/shared/native-chat-reasoning-order.test.ts": 283, + "src/shared/native-chat-reasoning-row.test.ts": 78, + "src/shared/native-chat-scope-cache.test.ts": 47, + "src/shared/native-chat-session-option-commands.test.ts": 79, + "src/shared/native-chat-session-option-defaults.test.ts": 95, + "src/shared/native-chat-session-option-snapshot.test.ts": 126, + "src/shared/native-chat-session-option-state.test.ts": 57, + "src/shared/native-chat-shell-environment.test.ts": 53, + "src/shared/native-chat-slash-commands.omp.test.ts": 48, + "src/shared/native-chat-slash-commands.test.ts": 59, + "src/shared/native-chat-stopped-send-turn.test.ts": 336, + "src/shared/native-chat-stream-unsubscribe.test.ts": 50, + "src/shared/native-chat-streaming.test.ts": 46, + "src/shared/native-chat-subagent-group-header.test.ts": 60, + "src/shared/native-chat-subagent-summary.test.ts": 58, + "src/shared/native-chat-task-list.test.ts": 58, + "src/shared/native-chat-tool-activity.test.ts": 57, + "src/shared/native-chat-tool-attribution-allocation.test.ts": 102, + "src/shared/native-chat-tool-fold-attribution.test.ts": 52, + "src/shared/native-chat-tool-icon.test.ts": 73, + "src/shared/native-chat-tool-identity.test.ts": 66, + "src/shared/native-chat-tool-line-identity.test.ts": 42, + "src/shared/native-chat-tool-pair-limit.test.ts": 128, + "src/shared/native-chat-tool-pairing.performance.test.ts": 500, + "src/shared/native-chat-tool-pairing.test.ts": 53, + "src/shared/native-chat-tool-preview-prefix.test.ts": 52, + "src/shared/native-chat-tool-preview-scan.test.ts": 52, + "src/shared/native-chat-tool-result-orphans.test.ts": 282, + "src/shared/native-chat-tool-run-outcome.test.ts": 81, + "src/shared/native-chat-tool-run-sentence.test.ts": 64, + "src/shared/native-chat-tool-summary.test.ts": 80, + "src/shared/native-chat-transcript-projection.test.ts": 268, + "src/shared/native-chat-transcript-retention.test.ts": 57, + "src/shared/native-chat-turn-activity.test.ts": 230, + "src/shared/native-chat-turn-fold.test.ts": 57, + "src/shared/native-chat-turn-grouping.test.ts": 231, + "src/shared/native-chat-turn-membership-derived-tool-row.test.ts": 340, + "src/shared/native-chat-turn-membership.test.ts": 287, + "src/shared/native-chat-turn-status.test.ts": 75, + "src/shared/native-chat-types.test.ts": 55, + "src/shared/native-chat-unverifiable-turn-status.test.ts": 257, + "src/shared/native-chat-upgrade-tip-audience.test.ts": 45, + "src/shared/native-chat-visual-directive.test.ts": 64, + "src/shared/native-chat-visual-height-governor.test.ts": 289, + "src/shared/native-chat-visual-shell.test.ts": 274, + "src/shared/native-chat-work-run.test.ts": 310, + "src/shared/native-file-drop.test.ts": 60, + "src/shared/nested-repo-telemetry-schema.test.ts": 299, "src/shared/nested-repo-telemetry.test.ts": 50, - "src/shared/nested-worker-depth.test.ts": 37, - "src/shared/network-proxy.test.ts": 52, - "src/shared/network/manual-address.test.ts": 82, - "src/shared/network/server-share-address.test.ts": 30, - "src/shared/new-workspace-dialog-repo.test.ts": 25, - "src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 92, - "src/shared/new-workspace/work-item-lookup-text.test.ts": 26, - "src/shared/new-workspace/workspace-source.test.ts": 51, - "src/shared/new-workspace/worktree-create-retry-policy.test.ts": 38, - "src/shared/node-bounded-file-reader-sync.test.ts": 36, - "src/shared/node-bounded-file-reader.test.ts": 35, - "src/shared/node-bounded-json-stringify.test.ts": 73, - "src/shared/node-file-content-equality.test.ts": 55, - "src/shared/node-markdown-document-discovery.test.ts": 57, - "src/shared/node-pty-spawn-helper.test.ts": 34, - "src/shared/node-readable-text.test.ts": 54, - "src/shared/node-source-copy-content-equality.test.ts": 69, - "src/shared/notebook-venv-location.test.ts": 44, - "src/shared/nul-delimited-fields.test.ts": 30, - "src/shared/nvm-default-alias.test.ts": 56, - "src/shared/omp-draft-launch-shell.test.ts": 173, - "src/shared/omp-draft-launch-windows.test.ts": 86, - "src/shared/omp-fresh-launch-shell.test.ts": 134, - "src/shared/omp-fresh-launch.test.ts": 102, - "src/shared/omp-owner-state-title.test.ts": 88, - "src/shared/omp-pi-semantic-title-preservation.test.ts": 49, - "src/shared/omp-source-control-discovery.test.ts": 150, - "src/shared/onboarding-tour-telemetry-events.test.ts": 165, - "src/shared/open-in-applications.test.ts": 36, - "src/shared/opencode-launch-command.test.ts": 52, - "src/shared/opencode-permission-status.test.ts": 154, - "src/shared/opencode-terminal-title.test.ts": 41, - "src/shared/orca-session-address.test.ts": 189, - "src/shared/orca-yaml-alias-bounds.test.ts": 102, - "src/shared/orca-yaml-bounds.test.ts": 64, - "src/shared/orcad-artifacts.test.ts": 27, - "src/shared/orcad-profile-preflight.test.ts": 135, - "src/shared/orchestration-ask-timeout.test.ts": 46, - "src/shared/orchestration-check-output.test.ts": 50, - "src/shared/orchestration-compatibility-evidence.test.ts": 41, - "src/shared/orchestration-dispatch-refusal-contract.test.ts": 71, - "src/shared/orchestration-fleet-attention.test.ts": 38, - "src/shared/orchestration-fleet-evidence-clock.test.ts": 39, - "src/shared/orchestration-fleet-projection.test.ts": 64, - "src/shared/orchestration-rpc-contract.test.ts": 34, - "src/shared/orchestration-task-display.test.ts": 38, - "src/shared/orchestration-task-summary.test.ts": 45, - "src/shared/orchestration-timing-budgets.test.ts": 38, - "src/shared/osc-title-scan-tail-retention.test.ts": 59, - "src/shared/osc-title-scan-tail.test.ts": 36, - "src/shared/osc52-clipboard-settings.test.ts": 40, - "src/shared/own-retained-string.test.ts": 115, - "src/shared/pairing-address-auto-selection.test.ts": 58, - "src/shared/pairing-local-ui-fields.test.ts": 29, - "src/shared/pairing.test.ts": 112, - "src/shared/pane-agent-identity-adapter.test.ts": 83, - "src/shared/pane-agent-identity-inventory.test.ts": 1015, - "src/shared/pane-agent-identity-resolver.test.ts": 90, - "src/shared/pane-agent-identity-surface-inventory.test.ts": 2736, - "src/shared/pane-agent-identity-title-corpus.test.ts": 95, - "src/shared/pane-agent-owner.test.ts": 53, - "src/shared/pane-key-alias.test.ts": 69, - "src/shared/physical-exit-tracker.test.ts": 45, - "src/shared/pi-agent-kind.test.ts": 58, + "src/shared/nested-worker-depth.test.ts": 51, + "src/shared/network-proxy.test.ts": 42, + "src/shared/network/manual-address.test.ts": 60, + "src/shared/network/server-share-address.test.ts": 58, + "src/shared/new-workspace-dialog-repo.test.ts": 51, + "src/shared/new-workspace/smart-workspace-linear-intent.test.ts": 67, + "src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 77, + "src/shared/new-workspace/work-item-lookup-text.test.ts": 59, + "src/shared/new-workspace/workspace-source.test.ts": 65, + "src/shared/new-workspace/worktree-create-retry-policy.test.ts": 49, + "src/shared/node-bounded-file-reader-sync.test.ts": 53, + "src/shared/node-bounded-file-reader.test.ts": 68, + "src/shared/node-bounded-json-stringify.test.ts": 77, + "src/shared/node-file-content-equality.test.ts": 44, + "src/shared/node-pty-spawn-helper.test.ts": 48, + "src/shared/node-readable-text.test.ts": 72, + "src/shared/node-source-copy-content-equality.test.ts": 65, + "src/shared/notebook-venv-location.test.ts": 46, + "src/shared/nul-delimited-fields.test.ts": 45, + "src/shared/nvm-default-alias.test.ts": 90, + "src/shared/omp-draft-launch-shell.test.ts": 247, + "src/shared/omp-draft-launch-windows.test.ts": 122, + "src/shared/omp-fresh-launch-shell.test.ts": 176, + "src/shared/omp-fresh-launch.test.ts": 130, + "src/shared/omp-owner-state-title.test.ts": 91, + "src/shared/omp-pi-semantic-title-preservation.test.ts": 107, + "src/shared/omp-source-control-discovery.test.ts": 287, + "src/shared/onboarding-tour-telemetry-events.test.ts": 342, + "src/shared/open-in-applications.test.ts": 54, + "src/shared/opencode-cli-version.test.ts": 64, + "src/shared/opencode-config-directory.test.ts": 44, + "src/shared/opencode-generation-command.test.ts": 82, + "src/shared/opencode-generation-output.test.ts": 230, + "src/shared/opencode-headless-command.test.ts": 63, + "src/shared/opencode-installed-plugin.test.ts": 61, + "src/shared/opencode-launch-command.test.ts": 61, + "src/shared/opencode-permission-status.test.ts": 213, + "src/shared/opencode-plugin-atomic-write.test.ts": 84, + "src/shared/opencode-plugin-permissions.test.ts": 117, + "src/shared/opencode-startup-prompt.test.ts": 178, + "src/shared/opencode-terminal-title.test.ts": 50, + "src/shared/opencode-tui-config-registration.test.ts": 85, + "src/shared/opencode-tui-plugin-install.test.ts": 59, + "src/shared/opencode2-resume-launch-mode.test.ts": 158, + "src/shared/openssh-default-shell.test.ts": 59, + "src/shared/orca-session-address.test.ts": 291, + "src/shared/orca-yaml-alias-bounds.test.ts": 398, + "src/shared/orca-yaml-bounds.test.ts": 216, + "src/shared/orcad-artifacts.test.ts": 85, + "src/shared/orcad-build-identity.test.ts": 61, + "src/shared/orcad-migration-dormant-session-validation.test.ts": 344, + "src/shared/orcad-migration-manifest-contract.test.ts": 358, + "src/shared/orcad-migration-manifest.test.ts": 432, + "src/shared/orcad-migration-scrollback.test.ts": 271, + "src/shared/orcad-migration-staged-catalog-validation.test.ts": 381, + "src/shared/orcad-profile-preflight.test.ts": 217, + "src/shared/orcad-stop-request.test.ts": 217, + "src/shared/orchestration-ask-timeout.test.ts": 49, + "src/shared/orchestration-check-output.test.ts": 66, + "src/shared/orchestration-compatibility-evidence.test.ts": 53, + "src/shared/orchestration-dispatch-refusal-contract.test.ts": 88, + "src/shared/orchestration-fleet-attention.test.ts": 46, + "src/shared/orchestration-fleet-evidence-clock.test.ts": 63, + "src/shared/orchestration-fleet-projection.test.ts": 105, + "src/shared/orchestration-rpc-contract.test.ts": 87, + "src/shared/orchestration-task-display.test.ts": 40, + "src/shared/orchestration-task-summary.test.ts": 53, + "src/shared/orchestration-timing-budgets.test.ts": 49, + "src/shared/osc-title-scan-tail-retention.test.ts": 82, + "src/shared/osc-title-scan-tail.test.ts": 51, + "src/shared/osc52-clipboard-settings.test.ts": 66, + "src/shared/own-retained-string.test.ts": 181, + "src/shared/pairing-address-auto-selection.test.ts": 54, + "src/shared/pairing-local-ui-fields.test.ts": 46, + "src/shared/pairing.test.ts": 249, + "src/shared/pane-agent-identity-adapter.test.ts": 95, + "src/shared/pane-agent-identity-resolver.test.ts": 89, + "src/shared/pane-agent-identity-title-corpus.test.ts": 73, + "src/shared/pane-agent-owner.test.ts": 52, + "src/shared/pane-key-alias.test.ts": 52, + "src/shared/physical-exit-tracker.test.ts": 51, + "src/shared/pi-agent-kind.test.ts": 68, "src/shared/pi-overlay-ui-settings.test.ts": 50, - "src/shared/pi-state-title-marker.test.ts": 72, - "src/shared/plugins/plugin-consent-fingerprint.test.ts": 159, - "src/shared/plugins/plugin-consent-request.test.ts": 84, - "src/shared/plugins/plugin-content-pack-contributions.test.ts": 136, - "src/shared/plugins/plugin-demo-fixture.test.ts": 177, - "src/shared/plugins/plugin-hostile-fixture.test.ts": 53, - "src/shared/plugins/plugin-install-lockfile.test.ts": 134, - "src/shared/plugins/plugin-kill-list.test.ts": 208, - "src/shared/plugins/plugin-language-pack-artifact.test.ts": 176, - "src/shared/plugins/plugin-manifest.test.ts": 145, - "src/shared/plugins/plugin-marketplace.test.ts": 224, - "src/shared/plugins/plugin-panel-call-admission.test.ts": 139, - "src/shared/plugins/plugin-panel-message-budget-traversal.test.ts": 196, - "src/shared/plugins/plugin-panel-message-budget.test.ts": 140, - "src/shared/plugins/plugin-panel-pong-frame.test.ts": 134, - "src/shared/plugins/plugin-panel-shell.test.ts": 566, - "src/shared/plugins/plugin-path-safety.test.ts": 161, - "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 172, - "src/shared/posix-wait-status.test.ts": 61, - "src/shared/powershell-native-argument.test.ts": 23, - "src/shared/pr-bot-author-overrides.test.ts": 36, - "src/shared/pr-check-severity-order.test.ts": 36, + "src/shared/pi-state-title-marker.test.ts": 76, + "src/shared/plugins/plugin-consent-fingerprint.test.ts": 290, + "src/shared/plugins/plugin-consent-request.test.ts": 215, + "src/shared/plugins/plugin-content-pack-contributions.test.ts": 292, + "src/shared/plugins/plugin-install-lockfile.test.ts": 285, + "src/shared/plugins/plugin-kill-list.test.ts": 298, + "src/shared/plugins/plugin-language-pack-artifact.test.ts": 177, + "src/shared/plugins/plugin-manifest.test.ts": 265, + "src/shared/plugins/plugin-marketplace.test.ts": 314, + "src/shared/plugins/plugin-panel-call-admission.test.ts": 246, + "src/shared/plugins/plugin-panel-message-budget-traversal.test.ts": 297, + "src/shared/plugins/plugin-panel-message-budget.test.ts": 278, + "src/shared/plugins/plugin-panel-pong-frame.test.ts": 243, + "src/shared/plugins/plugin-panel-shell.test.ts": 343, + "src/shared/plugins/plugin-path-safety.test.ts": 304, + "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 342, + "src/shared/posix-wait-status.test.ts": 50, + "src/shared/powershell-native-argument.test.ts": 33, + "src/shared/powershell-native-argument.windows.test.ts": 65, + "src/shared/pr-bot-author-overrides.test.ts": 54, + "src/shared/pr-check-severity-order.test.ts": 56, "src/shared/pr-check-status.test.ts": 50, - "src/shared/pr-comment-audience.test.ts": 52, - "src/shared/pr-comment-groups.test.ts": 33, - "src/shared/pr-comment-time.test.ts": 49, - "src/shared/preferred-git-remote.test.ts": 27, - "src/shared/priority-semaphore.test.ts": 38, - "src/shared/process-output-field-scanner.test.ts": 49, - "src/shared/process-table-index.test.ts": 152, - "src/shared/process-table-snapshot.test.ts": 174, - "src/shared/profile-state-recovery-command.test.ts": 156, - "src/shared/profile-state-telemetry-schema.test.ts": 187, - "src/shared/project-catalog-row-normalization.test.ts": 64, - "src/shared/project-execution-runtime.test.ts": 47, - "src/shared/project-groups.test.ts": 141, - "src/shared/project-host-setup-projection.test.ts": 45, - "src/shared/project-identity-succession.test.ts": 54, - "src/shared/promise-settlement-waiters.test.ts": 305, - "src/shared/protocol-compat.test.ts": 43, - "src/shared/pty-consumer-session.test.ts": 34, - "src/shared/pty-delivery-diagnostics.test.ts": 46, - "src/shared/pty-liveness-verdict.test.ts": 27, - "src/shared/pty-owner-backend.test.ts": 25, - "src/shared/pty-slave-line-discipline-echo.test.ts": 42, - "src/shared/pty-startup-ingress-kitty.test.ts": 67, - "src/shared/pty-startup-ingress-live-query-reply.test.ts": 54, - "src/shared/pty-startup-ingress.test.ts": 148, - "src/shared/pty-startup-reply-echo-shapes.test.ts": 67, - "src/shared/published-pane-agent-identity.test.ts": 82, - "src/shared/pull-request-generation.test.ts": 79, - "src/shared/quick-open-directory-reader.test.ts": 64, - "src/shared/quick-open-expansion-paths.test.ts": 60, - "src/shared/quick-open-filter.renderer-safety.test.ts": 53, - "src/shared/quick-open-filter.test.ts": 66, - "src/shared/quick-open-fuzzy-scan.test.ts": 1098, - "src/shared/quick-open-git-directory-collapse.test.ts": 140, - "src/shared/quick-open-install-rg.test.ts": 27, + "src/shared/pr-comment-audience.test.ts": 54, + "src/shared/pr-comment-groups.test.ts": 43, + "src/shared/pr-comment-time.test.ts": 45, + "src/shared/preferred-git-remote.test.ts": 53, + "src/shared/priority-semaphore.test.ts": 63, + "src/shared/process-output-field-scanner.test.ts": 55, + "src/shared/process-table-index.test.ts": 176, + "src/shared/process-table-snapshot.test.ts": 175, + "src/shared/profile-state-build-compatibility.test.ts": 52, + "src/shared/profile-state-recovery-command.test.ts": 248, + "src/shared/profile-state-telemetry-schema.test.ts": 295, + "src/shared/project-catalog-row-normalization.test.ts": 65, + "src/shared/project-execution-runtime.test.ts": 62, + "src/shared/project-groups.test.ts": 148, + "src/shared/project-host-setup-projection.test.ts": 99, + "src/shared/project-identity-succession.test.ts": 81, + "src/shared/promise-settlement-waiters.test.ts": 169, + "src/shared/protocol-compat.test.ts": 88, + "src/shared/pty-consumer-session.test.ts": 77, + "src/shared/pty-delivery-diagnostics.test.ts": 55, + "src/shared/pty-liveness-verdict.test.ts": 46, + "src/shared/pty-owner-backend.test.ts": 45, + "src/shared/pty-owner-color-query-colors.test.ts": 66, + "src/shared/pty-running-work-probe.test.ts": 61, + "src/shared/pty-slave-line-discipline-echo.test.ts": 64, + "src/shared/pty-startup-ingress-kitty.test.ts": 106, + "src/shared/pty-startup-ingress-lifetime-colors.test.ts": 90, + "src/shared/pty-startup-ingress-live-query-reply.test.ts": 119, + "src/shared/pty-startup-ingress.test.ts": 155, + "src/shared/pty-startup-reply-echo-shapes.test.ts": 59, + "src/shared/published-pane-agent-identity.test.ts": 90, + "src/shared/pull-request-generation.test.ts": 71, + "src/shared/qoder-agent.test.ts": 293, + "src/shared/qoder-cn-launch-recipe.test.ts": 147, + "src/shared/quick-open-filter.renderer-safety.test.ts": 59, + "src/shared/quick-open-filter.test.ts": 93, + "src/shared/quick-open-fuzzy-scan.test.ts": 996, + "src/shared/quick-open-git-directory-collapse.test.ts": 88, "src/shared/quick-open-listing-limits.test.ts": 45, - "src/shared/quick-open-readdir-memory.test.ts": 73, - "src/shared/quick-open-readdir-walk.test.ts": 445, - "src/shared/quick-open-transport-budget.test.ts": 43, - "src/shared/raster-image-base64-preview.test.ts": 501, - "src/shared/raster-image-dimensions.test.ts": 53, - "src/shared/raster-image-preview-limits.test.ts": 57, - "src/shared/rate-limit-reset-format.test.ts": 50, - "src/shared/rate-limit-types.test.ts": 32, - "src/shared/react-update-depth-attribution.test.ts": 48, - "src/shared/reconnect-jitter.test.ts": 34, - "src/shared/relay-frame-buffer.test.ts": 517, - "src/shared/relay-optional-artifacts.test.ts": 42, - "src/shared/relay-version-marker.test.ts": 42, - "src/shared/release-channel.test.ts": 36, - "src/shared/remote-foreground-evidence.test.ts": 30, - "src/shared/remote-pairing-address.test.ts": 143, - "src/shared/remote-pairing-verification.test.ts": 52, - "src/shared/remote-rpc-content-budget.test.ts": 93, - "src/shared/remote-runtime-client-error-classification.test.ts": 70, - "src/shared/remote-runtime-client.test.ts": 4934, - "src/shared/remote-runtime-connect-bound.test.ts": 387, - "src/shared/remote-runtime-memory-limits.test.ts": 159, - "src/shared/remote-runtime-outbound-admission.test.ts": 1513, - "src/shared/remote-runtime-request-connection-stale.test.ts": 297, - "src/shared/remote-runtime-request-connection.test.ts": 349, - "src/shared/remote-runtime-request-frames.test.ts": 99, - "src/shared/remote-runtime-request-response-router.test.ts": 174, - "src/shared/remote-runtime-request-websocket.test.ts": 180, - "src/shared/remote-runtime-shared-control-boundary.test.ts": 3236, - "src/shared/remote-runtime-shared-control-connection.test.ts": 3372, - "src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts": 150, - "src/shared/remote-runtime-shared-control-reconnect.test.ts": 176, - "src/shared/remote-runtime-shared-control-retired-request-ids.test.ts": 47, - "src/shared/remote-runtime-shared-control-socket-generation.test.ts": 111, - "src/shared/remote-runtime-shared-control-standing-intent.test.ts": 883, - "src/shared/remote-runtime-shared-control-subscription-close.test.ts": 128, - "src/shared/remote-runtime-shared-control-subscriptions.test.ts": 161, - "src/shared/remote-runtime-socket-liveness.test.ts": 33, - "src/shared/remote-runtime-subscription-connect-bound.test.ts": 293, - "src/shared/remote-runtime-subscription-frame-router.test.ts": 89, - "src/shared/remote-runtime-subscription-request.test.ts": 526, - "src/shared/remote-runtime-tailscale-hint.test.ts": 41, - "src/shared/remote-runtime-transport-error-agreement.test.ts": 353, - "src/shared/remote-workspace-session-projection.test.ts": 156, - "src/shared/renderer-restart-preparation.test.ts": 537, - "src/shared/repo-badge-color.test.ts": 181, - "src/shared/repo-icon.test.ts": 68, - "src/shared/repo-ref-maintenance.test.ts": 861, - "src/shared/repo-search-limits.test.ts": 41, - "src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts": 82, - "src/shared/repro-7732-gitlab-job-id-dropped.test.ts": 52, - "src/shared/repro-8478-opencode-native-title-icon.test.ts": 69, - "src/shared/require-tui-agent-config.test.ts": 51, - "src/shared/resolved-worktree-lineage.test.ts": 38, - "src/shared/retired-pty-incarnations.test.ts": 44, - "src/shared/review-head-tracking-ref.test.ts": 56, - "src/shared/ripgrep-process-availability.test.ts": 70, - "src/shared/rpc-contract/agent-launch-params.test.ts": 167, - "src/shared/rpc-contract/terminal-create-shell-param.test.ts": 156, - "src/shared/runtime-client-export-parity.test.ts": 136, - "src/shared/runtime-environment-store.test.ts": 259, - "src/shared/runtime-host-status-owner.test.ts": 40, - "src/shared/runtime-listing-host-scope.test.ts": 38, - "src/shared/runtime-navigation.test.ts": 49, - "src/shared/runtime-rpc-call-queue-retention.test.ts": 215, - "src/shared/runtime-rpc-call-queue.test.ts": 77, - "src/shared/runtime-workspace-file-owner.test.ts": 45, - "src/shared/runtime-workspace-window-availability.test.ts": 57, - "src/shared/search-match-count.test.ts": 48, - "src/shared/search-subprocess-lines.test.ts": 117, - "src/shared/secret-store.test.ts": 39, - "src/shared/secure-file-coarse-ctime.test.ts": 51, - "src/shared/secure-file-fsync-flags.test.ts": 93, - "src/shared/secure-file.test.ts": 713, - "src/shared/secure-path-hardening-cache.test.ts": 37, - "src/shared/secure-path-hardening-retry-budget.test.ts": 65, - "src/shared/secure-path-windows-acl.win32.test.ts": 84, - "src/shared/serve-option-validation.test.ts": 65, - "src/shared/session-tab-placement.test.ts": 40, - "src/shared/setup-agent-sequencing.test.ts": 4298, - "src/shared/setup-agent-sequencing.windows.test.ts": 39, - "src/shared/setup-runner-command.test.ts": 58, - "src/shared/setup-script-imports.test.ts": 101, - "src/shared/setup-script-package-manager-suggestion.test.ts": 37, - "src/shared/setup-script-shebang.test.ts": 53, - "src/shared/setup-script-telemetry-events.test.ts": 153, - "src/shared/setup-script-telemetry.test.ts": 63, - "src/shared/sha256.test.ts": 75, - "src/shared/shell-foreground-snapshot.test.ts": 157, - "src/shared/shell-process-readiness.test.ts": 66, - "src/shared/skill-bundle-install-contract.test.ts": 123, - "src/shared/skill-bundle-name.test.ts": 37, - "src/shared/skill-delete-contract.test.ts": 127, - "src/shared/skill-deletion-eligibility.test.ts": 62, - "src/shared/skill-install-contract.test.ts": 145, - "src/shared/skill-install-failure.test.ts": 88, - "src/shared/skill-metadata.test.ts": 40, - "src/shared/skill-package-manifest.test.ts": 233, - "src/shared/skill-path-containment.test.ts": 35, - "src/shared/skills-cli-agent-keys.test.ts": 40, - "src/shared/source-control-ai-action-recipes.test.ts": 176, - "src/shared/source-control-ai-action-variables.test.ts": 52, - "src/shared/source-control-ai-actions.test.ts": 65, - "src/shared/source-control-ai-policy-regression.test.ts": 220, - "src/shared/source-control-ai-recipe-save.test.ts": 179, - "src/shared/source-control-ai.test.ts": 148, - "src/shared/source-control-create-review-intent.test.ts": 37, - "src/shared/source-control-group-order.test.ts": 52, - "src/shared/source-control-primary-action-decision.test.ts": 55, - "src/shared/source-control-push-failure.test.ts": 60, - "src/shared/source-control-recovery-agent-command.test.ts": 62, - "src/shared/source-scan/source-tree-scan.test.ts": 80, - "src/shared/source-scan/source-tree-walk.test.ts": 58, - "src/shared/ssh-pending-pty-kill.test.ts": 47, - "src/shared/ssh-pty-id.test.ts": 56, - "src/shared/ssh-relay-pty-ownership-proof.test.ts": 53, - "src/shared/ssh-retained-payload-admission.test.ts": 68, - "src/shared/ssh-target-generation.test.ts": 50, - "src/shared/ssh-types.test.ts": 61, - "src/shared/stable-pane-id.test.ts": 37, - "src/shared/startup-command-submission.test.ts": 35, - "src/shared/status-bar-usage-mode.test.ts": 40, - "src/shared/string-chunk-compaction.test.ts": 44, - "src/shared/structural-value-equality.test.ts": 47, - "src/shared/structured-agent-session-agent-status.test.ts": 67, - "src/shared/structured-agent-session-coalescer.test.ts": 49, - "src/shared/structured-agent-session-composer.test.ts": 50, - "src/shared/structured-agent-session-context-usage.test.ts": 44, - "src/shared/structured-agent-session-create.test.ts": 64, - "src/shared/structured-agent-session-item-retention.test.ts": 937, - "src/shared/structured-agent-session-live-turn.test.ts": 117, - "src/shared/structured-agent-session-model-catalog-application.test.ts": 61, - "src/shared/structured-agent-session-mutation.test.ts": 49, - "src/shared/structured-agent-session-option-codec.test.ts": 25, - "src/shared/structured-agent-session-option-picks.test.ts": 79, - "src/shared/structured-agent-session-options.test.ts": 81, - "src/shared/structured-agent-session-projection.test.ts": 83, - "src/shared/structured-agent-session-reducer.test.ts": 146, - "src/shared/structured-agent-session-send-disposition.test.ts": 54, - "src/shared/structured-agent-session-status-started-at.test.ts": 94, - "src/shared/structured-agent-session-status-tool-line.test.ts": 77, - "src/shared/structured-agent-session-turn-timing-retention.test.ts": 185, - "src/shared/structured-agent-session-turn-timing.test.ts": 95, - "src/shared/structured-agent-turn-clock-anchor.test.ts": 36, - "src/shared/structured-native-chat-launch-route.test.ts": 38, - "src/shared/subprocess-stdin-write.test.ts": 54, - "src/shared/synthetic-agent-title.test.ts": 35, - "src/shared/tab-title-resolution.test.ts": 32, - "src/shared/tailnet-address.test.ts": 44, - "src/shared/task-providers.test.ts": 41, - "src/shared/task-query.test.ts": 51, - "src/shared/task-source-context.test.ts": 93, - "src/shared/telemetry-common-props.test.ts": 183, - "src/shared/telemetry-events-feature-education.test.ts": 198, - "src/shared/telemetry-events.test.ts": 189, - "src/shared/telemetry-feature-wall-events.test.ts": 175, - "src/shared/telemetry-orca-cli-feature-tip.test.ts": 182, - "src/shared/terminal-bell-detector.test.ts": 42, - "src/shared/terminal-color-scheme-protocol.test.ts": 50, - "src/shared/terminal-composer-draft.test.ts": 49, - "src/shared/terminal-custom-themes.test.ts": 42, - "src/shared/terminal-escape-introducer.test.ts": 61, - "src/shared/terminal-exit-cause.test.ts": 30, - "src/shared/terminal-file-url-target.test.ts": 36, - "src/shared/terminal-fonts.test.ts": 28, - "src/shared/terminal-github-pr-link-detector.test.ts": 73, - "src/shared/terminal-input.test.ts": 68, - "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts": 61, - "src/shared/terminal-kitty-keyboard-tail-retention.test.ts": 180, - "src/shared/terminal-line-height-settings.test.ts": 51, - "src/shared/terminal-mode-2031-final-state.test.ts": 90, - "src/shared/terminal-mode-reset-profiles.test.ts": 95, - "src/shared/terminal-osc-color-reply.test.ts": 28, - "src/shared/terminal-osc133-carry-retention.test.ts": 266, - "src/shared/terminal-output-side-effects.test.ts": 79, - "src/shared/terminal-partial-escape-tail-ground-scan.test.ts": 41, - "src/shared/terminal-partial-escape-tail.fuzz.test.ts": 1380, + "src/shared/quick-open-query-target.test.ts": 64, + "src/shared/quick-open-recent-candidates.test.ts": 63, + "src/shared/quick-open-transport-budget.test.ts": 55, + "src/shared/raster-image-base64-preview.test.ts": 391, + "src/shared/raster-image-dimensions.test.ts": 58, + "src/shared/raster-image-preview-limits.test.ts": 63, + "src/shared/rate-limit-reset-format.test.ts": 46, + "src/shared/react-update-depth-attribution.test.ts": 46, + "src/shared/reconnect-jitter.test.ts": 42, + "src/shared/relay-frame-buffer.test.ts": 275, + "src/shared/relay-optional-artifacts.test.ts": 53, + "src/shared/relay-version-marker.test.ts": 51, + "src/shared/release-channel.test.ts": 75, + "src/shared/remote-foreground-evidence.test.ts": 64, + "src/shared/remote-pairing-address.test.ts": 268, + "src/shared/remote-pairing-verification.test.ts": 64, + "src/shared/remote-rpc-content-budget.test.ts": 105, + "src/shared/remote-runtime-client-error-classification.test.ts": 50, + "src/shared/remote-runtime-client.test.ts": 5122, + "src/shared/remote-runtime-connect-bound.test.ts": 558, + "src/shared/remote-runtime-memory-limits.test.ts": 144, + "src/shared/remote-runtime-outbound-admission.test.ts": 1054, + "src/shared/remote-runtime-pty-id.test.ts": 45, + "src/shared/remote-runtime-request-connection-stale.test.ts": 312, + "src/shared/remote-runtime-request-connection.test.ts": 410, + "src/shared/remote-runtime-request-frames.test.ts": 208, + "src/shared/remote-runtime-request-response-router.test.ts": 244, + "src/shared/remote-runtime-request-websocket.test.ts": 300, + "src/shared/remote-runtime-shared-control-connection.test.ts": 3555, + "src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts": 279, + "src/shared/remote-runtime-shared-control-reconnect.test.ts": 263, + "src/shared/remote-runtime-shared-control-retired-request-ids.test.ts": 42, + "src/shared/remote-runtime-shared-control-socket-generation.test.ts": 221, + "src/shared/remote-runtime-shared-control-standing-intent.test.ts": 956, + "src/shared/remote-runtime-shared-control-subscription-close.test.ts": 255, + "src/shared/remote-runtime-shared-control-subscription-start.test.ts": 267, + "src/shared/remote-runtime-shared-control-subscriptions.test.ts": 277, + "src/shared/remote-runtime-socket-liveness.test.ts": 58, + "src/shared/remote-runtime-subscription-connect-bound.test.ts": 652, + "src/shared/remote-runtime-subscription-frame-router.test.ts": 249, + "src/shared/remote-runtime-subscription-request.test.ts": 699, + "src/shared/remote-runtime-tailscale-hint.test.ts": 59, + "src/shared/remote-runtime-transport-error-agreement.test.ts": 219, + "src/shared/remote-workspace-session-projection.test.ts": 346, + "src/shared/renderer-restart-preparation.test.ts": 169, + "src/shared/repo-badge-color.test.ts": 310, + "src/shared/repo-icon.test.ts": 80, + "src/shared/repo-pack-index-scheduling.test.ts": 86, + "src/shared/repo-ref-maintenance.test.ts": 507, + "src/shared/repo-search-limits.test.ts": 49, + "src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts": 105, + "src/shared/repro-7732-gitlab-job-id-dropped.test.ts": 51, + "src/shared/repro-8478-opencode-native-title-icon.test.ts": 86, + "src/shared/require-tui-agent-config.test.ts": 60, + "src/shared/resolved-worktree-lineage.test.ts": 60, + "src/shared/retired-pty-incarnations.test.ts": 50, + "src/shared/returned-draft-text.test.ts": 45, + "src/shared/review-head-tracking-ref.test.ts": 50, + "src/shared/review-refresh-policy.test.ts": 47, + "src/shared/ripgrep-dense-match-json.test.ts": 186, + "src/shared/ripgrep-filename-decoder.test.ts": 51, + "src/shared/ripgrep-line-decoding.test.ts": 388, + "src/shared/ripgrep-match-offsets.test.ts": 132, + "src/shared/ripgrep-process-availability.test.ts": 80, + "src/shared/ripgrep-search-diagnostics.test.ts": 72, + "src/shared/rpc-contract/agent-launch-params.test.ts": 408, + "src/shared/rpc-contract/automation-params-extra-agent-args.test.ts": 256, + "src/shared/rpc-contract/structured-agent-session-agent-params.test.ts": 292, + "src/shared/rpc-contract/terminal-create-shell-param.test.ts": 219, + "src/shared/rpc-contract/worktree-review-metadata-compatibility.test.ts": 277, + "src/shared/runtime-environment-managed-orcad-store.test.ts": 332, + "src/shared/runtime-environment-sidecar.test.ts": 340, + "src/shared/runtime-environment-ssh-access-store.test.ts": 416, + "src/shared/runtime-environment-store.test.ts": 353, + "src/shared/runtime-environments-ssh-access.test.ts": 247, + "src/shared/runtime-host-pairing-identity.test.ts": 52, + "src/shared/runtime-host-status-owner.test.ts": 83, + "src/shared/runtime-listing-host-scope.test.ts": 57, + "src/shared/runtime-navigation.test.ts": 47, + "src/shared/runtime-rpc-call-queue-retention.test.ts": 279, + "src/shared/runtime-rpc-call-queue.test.ts": 87, + "src/shared/runtime-source-env.test.ts": 51, + "src/shared/runtime-workspace-file-owner.test.ts": 50, + "src/shared/runtime-workspace-window-availability.test.ts": 49, + "src/shared/search-match-count.test.ts": 43, + "src/shared/search-subprocess-lines.test.ts": 99, + "src/shared/secret-at-rest-protection.test.ts": 50, + "src/shared/secret-store.test.ts": 43, + "src/shared/secure-file-coarse-ctime.test.ts": 61, + "src/shared/secure-file-fsync-flags.test.ts": 98, + "src/shared/secure-file.test.ts": 736, + "src/shared/secure-path-hardening-cache.test.ts": 59, + "src/shared/secure-path-hardening-retry-budget.test.ts": 91, + "src/shared/secure-path-windows-acl.win32.test.ts": 68, + "src/shared/serve-option-validation.test.ts": 63, + "src/shared/session-tab-placement.test.ts": 53, + "src/shared/setup-agent-sequencing-codex-no-daemon.test.ts": 1135, + "src/shared/setup-agent-sequencing.test.ts": 5411, + "src/shared/setup-agent-sequencing.windows.test.ts": 54, + "src/shared/setup-runner-command.test.ts": 80, + "src/shared/setup-script-imports.test.ts": 118, + "src/shared/setup-script-package-manager-suggestion.test.ts": 65, + "src/shared/setup-script-shebang.test.ts": 60, + "src/shared/setup-script-telemetry-events.test.ts": 297, + "src/shared/setup-script-telemetry.test.ts": 49, + "src/shared/sha256.test.ts": 61, + "src/shared/shell-foreground-snapshot.test.ts": 173, + "src/shared/shell-process-detection.test.ts": 55, + "src/shared/shell-process-readiness.test.ts": 68, + "src/shared/skill-bundle-install-contract.test.ts": 218, + "src/shared/skill-bundle-name.test.ts": 50, + "src/shared/skill-delete-contract.test.ts": 241, + "src/shared/skill-deletion-eligibility.test.ts": 56, + "src/shared/skill-install-contract.test.ts": 220, + "src/shared/skill-install-failure.test.ts": 202, + "src/shared/skill-metadata.test.ts": 47, + "src/shared/skill-package-manifest.test.ts": 350, + "src/shared/skill-path-containment.test.ts": 53, + "src/shared/skills-cli-agent-keys.test.ts": 49, + "src/shared/source-control-ai-action-recipes.test.ts": 306, + "src/shared/source-control-ai-action-variables.test.ts": 64, + "src/shared/source-control-ai-actions.test.ts": 66, + "src/shared/source-control-ai-policy-regression.test.ts": 281, + "src/shared/source-control-ai-recipe-save.test.ts": 276, + "src/shared/source-control-ai.test.ts": 278, + "src/shared/source-control-create-review-intent.test.ts": 60, + "src/shared/source-control-group-order.test.ts": 49, + "src/shared/source-control-launch-agent-selection.test.ts": 289, + "src/shared/source-control-primary-action-decision.test.ts": 62, + "src/shared/source-control-push-failure.test.ts": 68, + "src/shared/source-control-recovery-agent-command.test.ts": 77, + "src/shared/source-scan/source-tree-scan.test.ts": 100, + "src/shared/source-scan/source-tree-walk.test.ts": 71, + "src/shared/ssh-pending-pty-kill.test.ts": 55, + "src/shared/ssh-pty-id.test.ts": 49, + "src/shared/ssh-remote-pty-lease-predicates.test.ts": 43, + "src/shared/ssh-retained-payload-admission.test.ts": 78, + "src/shared/ssh-target-generation.test.ts": 57, + "src/shared/stable-pane-id.test.ts": 46, + "src/shared/startup-command-staging.node-pty.test.ts": 2051, + "src/shared/startup-command-staging.test.ts": 143, + "src/shared/startup-command-submission.test.ts": 42, + "src/shared/startup-line-prompt-carry.test.ts": 175, + "src/shared/status-bar-compact-change-notice.test.ts": 60, + "src/shared/status-bar-usage-mode.test.ts": 49, + "src/shared/string-chunk-compaction.test.ts": 63, + "src/shared/structural-value-equality.test.ts": 52, + "src/shared/structured-agent-session-agent-status.test.ts": 71, + "src/shared/structured-agent-session-child-work-legacy.test.ts": 65, + "src/shared/structured-agent-session-coalescer.test.ts": 215, + "src/shared/structured-agent-session-composer.test.ts": 82, + "src/shared/structured-agent-session-context-usage.test.ts": 226, + "src/shared/structured-agent-session-create.test.ts": 86, + "src/shared/structured-agent-session-dispatch-rejection.test.ts": 283, + "src/shared/structured-agent-session-draft-hand-off.test.ts": 46, + "src/shared/structured-agent-session-first-frame-picker.test.ts": 86, + "src/shared/structured-agent-session-first-prompt.test.ts": 227, + "src/shared/structured-agent-session-item-retention.test.ts": 941, + "src/shared/structured-agent-session-latest-request.test.ts": 353, + "src/shared/structured-agent-session-live-turn.test.ts": 248, + "src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts": 315, + "src/shared/structured-agent-session-message-projection.test.ts": 267, + "src/shared/structured-agent-session-model-catalog-application.test.ts": 77, + "src/shared/structured-agent-session-mutation.test.ts": 260, + "src/shared/structured-agent-session-opening-send.test.ts": 233, + "src/shared/structured-agent-session-option-codec.test.ts": 40, + "src/shared/structured-agent-session-option-picks.test.ts": 80, + "src/shared/structured-agent-session-options.test.ts": 78, + "src/shared/structured-agent-session-projection-sender.test.ts": 307, + "src/shared/structured-agent-session-projection.test.ts": 414, + "src/shared/structured-agent-session-reasoning-projection.test.ts": 308, + "src/shared/structured-agent-session-reducer.queued-messages.test.ts": 228, + "src/shared/structured-agent-session-reducer.test.ts": 274, + "src/shared/structured-agent-session-rejection-words.test.ts": 209, + "src/shared/structured-agent-session-send-evidence.test.ts": 225, + "src/shared/structured-agent-session-send-mutation.test.ts": 305, + "src/shared/structured-agent-session-status-started-at.test.ts": 291, + "src/shared/structured-agent-session-status-tool-line.test.ts": 324, + "src/shared/structured-agent-session-subagent-roster.test.ts": 64, + "src/shared/structured-agent-session-turn-bars.test.ts": 255, + "src/shared/structured-agent-session-turn-timing-retention.test.ts": 355, + "src/shared/structured-agent-session-turn-timing.test.ts": 301, + "src/shared/structured-agent-session-unknown-item.test.ts": 260, + "src/shared/structured-agent-turn-clock-anchor.test.ts": 290, + "src/shared/structured-chat-naming-eligibility.test.ts": 236, + "src/shared/structured-native-chat-launch-route.test.ts": 87, + "src/shared/structured-session-background-tasks-view.test.ts": 67, + "src/shared/structured-session-foreground-commands.test.ts": 230, + "src/shared/subprocess-stdin-write.test.ts": 49, + "src/shared/synthetic-agent-title.test.ts": 52, + "src/shared/tab-title-resolution.test.ts": 60, + "src/shared/tailnet-address.test.ts": 45, + "src/shared/task-providers.test.ts": 57, + "src/shared/task-query.test.ts": 61, + "src/shared/task-source-context.test.ts": 211, + "src/shared/telemetry-common-props.test.ts": 271, + "src/shared/telemetry-events-feature-education.test.ts": 298, + "src/shared/telemetry-events.test.ts": 361, + "src/shared/telemetry-feature-wall-events.test.ts": 290, + "src/shared/telemetry-orca-cli-feature-tip.test.ts": 307, + "src/shared/telemetry-workspace-create-schemas.test.ts": 242, + "src/shared/terminal-bell-detector.test.ts": 45, + "src/shared/terminal-color-scheme-protocol.test.ts": 45, + "src/shared/terminal-composer-draft.test.ts": 72, + "src/shared/terminal-custom-themes.test.ts": 54, + "src/shared/terminal-escape-introducer.test.ts": 55, + "src/shared/terminal-exit-cause.test.ts": 51, + "src/shared/terminal-file-url-target.test.ts": 42, + "src/shared/terminal-fonts.test.ts": 49, + "src/shared/terminal-github-pr-link-detector.test.ts": 71, + "src/shared/terminal-input.test.ts": 90, + "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts": 91, + "src/shared/terminal-kitty-keyboard-tail-retention.test.ts": 212, + "src/shared/terminal-leaf-move.test.ts": 43, + "src/shared/terminal-line-height-settings.test.ts": 55, + "src/shared/terminal-mode-2031-final-state.test.ts": 102, + "src/shared/terminal-mode-reset-profiles.test.ts": 84, + "src/shared/terminal-mouse-encoding.test.ts": 72, + "src/shared/terminal-osc-color-override-tracker.test.ts": 71, + "src/shared/terminal-osc-color-reply.test.ts": 45, + "src/shared/terminal-osc133-carry-retention.test.ts": 340, + "src/shared/terminal-output-side-effects.test.ts": 103, + "src/shared/terminal-pane-placement.test.ts": 328, + "src/shared/terminal-partial-escape-tail-ground-scan.test.ts": 61, + "src/shared/terminal-partial-escape-tail.fuzz.test.ts": 1435, "src/shared/terminal-partial-escape-tail.test.ts": 61, - "src/shared/terminal-query-reply.test.ts": 86, - "src/shared/terminal-quick-commands.test.ts": 33, - "src/shared/terminal-reply-query-scan.test.ts": 43, - "src/shared/terminal-scrollback-policy.test.ts": 53, - "src/shared/terminal-selection-gutter.test.ts": 50, - "src/shared/terminal-startup-cwd.test.ts": 220, - "src/shared/terminal-stream-json-byte-length.test.ts": 41, - "src/shared/terminal-stream-protocol.test.ts": 55, - "src/shared/terminal-title-agent-type.test.ts": 62, - "src/shared/terminal-title-classification-corpus.test.ts": 94, - "src/shared/terminal-title-classification-memo.test.ts": 49, - "src/shared/terminal-view-attributes.test.ts": 35, - "src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts": 33, - "src/shared/terminal-zero-dimensions-diagnostic.test.ts": 45, - "src/shared/test-code-path.test.ts": 41, - "src/shared/text-search.test.ts": 114, - "src/shared/timer-delay.test.ts": 52, - "src/shared/tui-agent-config.test.ts": 65, - "src/shared/tui-agent-launch-defaults.test.ts": 61, - "src/shared/tui-agent-permissions.test.ts": 41, - "src/shared/tui-agent-prompt-transport.test.ts": 94, - "src/shared/tui-agent-selection.test.ts": 47, - "src/shared/tui-agent-startup-copilot-resume.test.ts": 53, - "src/shared/tui-agent-startup-hermes.test.ts": 139, - "src/shared/tui-agent-startup-session-options.test.ts": 88, - "src/shared/tui-agent-startup-shell.test.ts": 108, - "src/shared/tui-agent-startup.test.ts": 119, - "src/shared/ui-language.test.ts": 36, - "src/shared/ui-locale.test.ts": 48, - "src/shared/ui-zoom-level.test.ts": 45, - "src/shared/updated-at-order.test.ts": 93, - "src/shared/updater-windows-signature-check.test.ts": 39, - "src/shared/usage-percentage-display-change-notice.test.ts": 94, - "src/shared/usage-percentage-display.test.ts": 40, - "src/shared/utf8-byte-limits.test.ts": 117, - "src/shared/uuid-v4.test.ts": 56, - "src/shared/vscode-remote-ssh-launcher.test.ts": 27, - "src/shared/window-shortcut-policy-agent-dashboard.test.ts": 48, - "src/shared/window-shortcut-policy.test.ts": 78, - "src/shared/windows-cmd-runner-delayed-launch.test.ts": 60, - "src/shared/windows-command-line-budget.test.ts": 1127, - "src/shared/windows-console-input.test.ts": 41, - "src/shared/windows-environment-expansion.test.ts": 132, - "src/shared/windows-interactive-login-spawn.test.ts": 42, - "src/shared/windows-lane-tree-removal-boundary.test.ts": 57, - "src/shared/windows-long-path-git-args.test.ts": 41, - "src/shared/windows-security-descriptor.test.ts": 33, - "src/shared/windows-terminal-shell.test.ts": 60, - "src/shared/windows-transient-lock-removal.test.ts": 1028, - "src/shared/worker-terminal-host-scope.test.ts": 65, - "src/shared/worker-transcript-background-task-text.test.ts": 56, - "src/shared/worker-transcript-text-scaling.test.ts": 46, - "src/shared/workspace-cleanup-applied-filters.test.ts": 60, - "src/shared/workspace-cleanup-browse-state-persistence.test.ts": 65, - "src/shared/workspace-cleanup-ui-state.test.ts": 39, - "src/shared/workspace-cleanup.test.ts": 40, - "src/shared/workspace-doc-history.test.ts": 59, - "src/shared/workspace-linked-item-equality.test.ts": 32, - "src/shared/workspace-linked-item-source-context.test.ts": 55, - "src/shared/workspace-name.test.ts": 55, - "src/shared/workspace-session-browser-history.test.ts": 173, - "src/shared/workspace-session-partition-owner.test.ts": 38, - "src/shared/workspace-session-salvage-equivalence.test.ts": 226, - "src/shared/workspace-session-salvage.test.ts": 1142, - "src/shared/workspace-session-schema-field-coverage.test.ts": 205, - "src/shared/workspace-session-schema.sleeping-agent.test.ts": 247, - "src/shared/workspace-session-schema.test.ts": 289, - "src/shared/workspace-session-tab-focus-schema.test.ts": 174, - "src/shared/workspace-session-terminal-buffers.test.ts": 276, - "src/shared/workspace-session-terminal-schema.test.ts": 155, - "src/shared/workspace-session-terminal-tab-close.test.ts": 242, - "src/shared/workspace-session-validation-work.test.ts": 279, - "src/shared/workspace-space-compaction.test.ts": 49, - "src/shared/workspace-space-entry-traversal.test.ts": 1467, - "src/shared/workspace-space-scan-budget.test.ts": 80, - "src/shared/workspace-statuses.test.ts": 70, - "src/shared/worktree-execution-host-resolution.test.ts": 48, - "src/shared/worktree-name-suggestion.test.ts": 39, - "src/shared/worktree-scan-failure.test.ts": 39, - "src/shared/worktree/archive-failure-blocking-capability.test.ts": 31, - "src/shared/worktree/base-ref.test.ts": 43, - "src/shared/worktree/card-properties.test.ts": 56, - "src/shared/worktree/catalog-version.test.ts": 29, - "src/shared/worktree/create-preparation.test.ts": 35, - "src/shared/worktree/github-pr-suppression.test.ts": 57, - "src/shared/worktree/host-context-labels.test.ts": 59, - "src/shared/worktree/host-qualified-identity.test.ts": 61, - "src/shared/worktree/id.test.ts": 58, - "src/shared/worktree/identity.test.ts": 38, - "src/shared/worktree/ownership-configured-base-visibility.test.ts": 77, - "src/shared/worktree/ownership-worktree-base-path.test.ts": 57, - "src/shared/worktree/ownership.test.ts": 264, - "src/shared/worktree/removal-fence-error.test.ts": 27, - "src/shared/worktree/removal-force-classification.test.ts": 30, + "src/shared/terminal-query-reply.test.ts": 112, + "src/shared/terminal-quick-commands.test.ts": 56, + "src/shared/terminal-reply-query-scan.test.ts": 56, + "src/shared/terminal-restore-parity-writes.test.ts": 135, + "src/shared/terminal-scrollback-policy.test.ts": 55, + "src/shared/terminal-selection-gutter.test.ts": 47, + "src/shared/terminal-session-state-save-failure.test.ts": 46, + "src/shared/terminal-startup-cwd.test.ts": 308, + "src/shared/terminal-stream-json-byte-length.test.ts": 65, + "src/shared/terminal-stream-protocol.test.ts": 63, + "src/shared/terminal-surface-close-target.test.ts": 51, + "src/shared/terminal-synchronized-output-scan.test.ts": 80, + "src/shared/terminal-theme-selection.test.ts": 53, + "src/shared/terminal-title-agent-type.test.ts": 90, + "src/shared/terminal-title-classification-corpus.test.ts": 152, + "src/shared/terminal-title-classification-memo.test.ts": 95, + "src/shared/terminal-view-attributes.test.ts": 60, + "src/shared/terminal-viewport.test.ts": 189, + "src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts": 48, + "src/shared/terminal-zero-dimensions-diagnostic.test.ts": 47, + "src/shared/test-code-path.test.ts": 54, + "src/shared/text-search-dense-matches.test.ts": 450, + "src/shared/text-search-glob-patterns.test.ts": 243, + "src/shared/text-search-invalid-filename.test.ts": 62, + "src/shared/text-search-paths.test.ts": 68, + "src/shared/text-search-snippet-boundaries.test.ts": 66, + "src/shared/text-search-snippet-retention.test.ts": 603, + "src/shared/text-search.test.ts": 129, + "src/shared/timer-delay.test.ts": 54, + "src/shared/tmux-agent-hook-owner.test.ts": 467, + "src/shared/tmux-client-attachment.test.ts": 64, + "src/shared/tmux-hook-refresh-retirement.test.ts": 621, + "src/shared/tmux-host-attachment-probe.test.ts": 45, + "src/shared/tui-agent-config.test.ts": 58, + "src/shared/tui-agent-fork-startup.test.ts": 112, + "src/shared/tui-agent-launch-command-argument-values.test.ts": 118, + "src/shared/tui-agent-launch-command-override.test.ts": 61, + "src/shared/tui-agent-launch-command-pick-flags.test.ts": 97, + "src/shared/tui-agent-launch-defaults.test.ts": 96, + "src/shared/tui-agent-permissions.test.ts": 42, + "src/shared/tui-agent-prompt-transport.test.ts": 147, + "src/shared/tui-agent-rest-signal.test.ts": 64, + "src/shared/tui-agent-selection.test.ts": 62, + "src/shared/tui-agent-startup-copilot-resume.test.ts": 117, + "src/shared/tui-agent-startup-hermes.test.ts": 145, + "src/shared/tui-agent-startup-kiro-resume.test.ts": 65, + "src/shared/tui-agent-startup-session-options.test.ts": 135, + "src/shared/tui-agent-startup-shell.test.ts": 129, + "src/shared/tui-agent-startup.test.ts": 191, + "src/shared/ui-language.test.ts": 41, + "src/shared/ui-locale.test.ts": 45, + "src/shared/ui-zoom-level.test.ts": 55, + "src/shared/updated-at-order.test.ts": 104, + "src/shared/updater-windows-signature-check.test.ts": 43, + "src/shared/usage-percentage-display-change-notice.test.ts": 51, + "src/shared/usage-percentage-display.test.ts": 44, + "src/shared/utf8-byte-limits.test.ts": 149, + "src/shared/uuid-v4.test.ts": 47, + "src/shared/vscode-remote-ssh-launcher.test.ts": 53, + "src/shared/window-shortcut-policy-agent-dashboard.test.ts": 72, + "src/shared/window-shortcut-policy.test.ts": 119, + "src/shared/windows-breakaway-launcher.test.ts": 82, + "src/shared/windows-breakaway-previous-stderr.test.ts": 67, + "src/shared/windows-cmd-runner-delayed-launch.test.ts": 43, + "src/shared/windows-command-line-budget.test.ts": 278, + "src/shared/windows-console-input.test.ts": 65, + "src/shared/windows-environment-expansion.test.ts": 108, + "src/shared/windows-interactive-login-spawn.test.ts": 64, + "src/shared/windows-long-path-git-args.test.ts": 49, + "src/shared/windows-security-descriptor.test.ts": 57, + "src/shared/windows-terminal-shell.test.ts": 53, + "src/shared/windows-transient-lock-removal.test.ts": 1054, + "src/shared/worker-terminal-host-scope.test.ts": 98, + "src/shared/worker-transcript-background-task-text.test.ts": 54, + "src/shared/worker-transcript-text-scaling.test.ts": 57, + "src/shared/workspace-attachment-mutation.test.ts": 112, + "src/shared/workspace-attachment-normalization.test.ts": 67, + "src/shared/workspace-attachment-origins.test.ts": 53, + "src/shared/workspace-attachments.test.ts": 291, + "src/shared/workspace-cleanup-applied-filters.test.ts": 76, + "src/shared/workspace-cleanup-browse-state-persistence.test.ts": 61, + "src/shared/workspace-cleanup-ui-state.test.ts": 50, + "src/shared/workspace-cleanup.test.ts": 49, + "src/shared/workspace-doc-history.test.ts": 70, + "src/shared/workspace-layout/tab-group-moves.test.ts": 58, + "src/shared/workspace-layout/workspace-layout-command-model.test.ts": 470, + "src/shared/workspace-layout/workspace-layout-command-persistence.test.ts": 339, + "src/shared/workspace-layout/workspace-layout-commands.test.ts": 187, + "src/shared/workspace-layout/workspace-layout-fact-locations.test.ts": 442, + "src/shared/workspace-layout/workspace-layout-legacy-persistence.test.ts": 359, + "src/shared/workspace-layout/workspace-layout-load-normalization.test.ts": 379, + "src/shared/workspace-layout/workspace-layout-minted-ids.test.ts": 41, + "src/shared/workspace-layout/workspace-layout-model-rules.test.ts": 340, + "src/shared/workspace-layout/workspace-layout-published.test.ts": 303, + "src/shared/workspace-layout/workspace-layout-round-trip.test.ts": 345, + "src/shared/workspace-layout/workspace-layout-stream-frames.test.ts": 275, + "src/shared/workspace-layout/workspace-layout-transitions.test.ts": 89, + "src/shared/workspace-linked-item-equality.test.ts": 45, + "src/shared/workspace-linked-item-source-context.test.ts": 62, + "src/shared/workspace-name.test.ts": 78, + "src/shared/workspace-reference-identity.test.ts": 96, + "src/shared/workspace-review-checks.test.ts": 86, + "src/shared/workspace-session-browser-history.test.ts": 307, + "src/shared/workspace-session-partition-owner.test.ts": 37, + "src/shared/workspace-session-salvage-equivalence.test.ts": 472, + "src/shared/workspace-session-salvage.test.ts": 951, + "src/shared/workspace-session-schema-field-coverage.test.ts": 345, + "src/shared/workspace-session-schema.sleeping-agent.test.ts": 377, + "src/shared/workspace-session-schema.test.ts": 379, + "src/shared/workspace-session-tab-focus-schema.test.ts": 360, + "src/shared/workspace-session-terminal-buffers.test.ts": 316, + "src/shared/workspace-session-terminal-schema.test.ts": 313, + "src/shared/workspace-session-terminal-tab-close.test.ts": 315, + "src/shared/workspace-session-validation-work.test.ts": 377, + "src/shared/workspace-space-compaction.test.ts": 61, + "src/shared/workspace-space-entry-traversal.test.ts": 849, + "src/shared/workspace-space-scan-budget.test.ts": 49, + "src/shared/workspace-statuses.test.ts": 63, + "src/shared/worktree-execution-host-resolution.test.ts": 106, + "src/shared/worktree-name-suggestion.test.ts": 72, + "src/shared/worktree-scan-failure.test.ts": 44, + "src/shared/worktree/archive-failure-blocking-capability.test.ts": 68, + "src/shared/worktree/base-ref.test.ts": 63, + "src/shared/worktree/card-properties.test.ts": 55, + "src/shared/worktree/catalog-version.test.ts": 45, + "src/shared/worktree/create-preparation.test.ts": 47, + "src/shared/worktree/github-pr-suppression.test.ts": 66, + "src/shared/worktree/host-context-labels.test.ts": 50, + "src/shared/worktree/host-qualified-identity.test.ts": 50, + "src/shared/worktree/id.test.ts": 71, + "src/shared/worktree/identity.test.ts": 57, + "src/shared/worktree/local-base-branch-fast-forward-real-git.test.ts": 1130, + "src/shared/worktree/local-base-branch-fast-forward.test.ts": 98, + "src/shared/worktree/ownership-configured-base-visibility.test.ts": 69, + "src/shared/worktree/ownership-worktree-base-path.test.ts": 56, + "src/shared/worktree/ownership.test.ts": 251, + "src/shared/worktree/removal-fence-error.test.ts": 54, + "src/shared/worktree/removal-force-classification.test.ts": 52, "src/shared/worktree/retired-name-cache.test.ts": 52, - "src/shared/worktree/retired-name-registry.test.ts": 289, - "src/shared/worktree/sort-order-update.test.ts": 43, - "src/shared/worktree/submodule-removal.test.ts": 48, - "src/shared/worktree/visibility-sources.test.ts": 87, - "src/shared/ws-outbound-backpressure-queue.test.ts": 67, - "src/shared/wsl-exec-mode-separator.test.ts": 1946, - "src/shared/wsl-login-shell-command.test.ts": 140, - "src/shared/wsl-paths.test.ts": 93, - "src/shared/zcode-missing-tui.test.ts": 43, - "src/shared/zcode-orchestration-contract.test.ts": 98, - "src/shared/zod-salvage-absence.test.ts": 106, - "tests/e2e/accumulated-workspace-state-builder.unit.test.ts": 88, - "tests/e2e/alternate-screen-fixture-script.unit.test.ts": 55, - "tests/e2e/codex-child-approval-activity-row.unit.test.ts": 4306, - "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 5834, - "tests/e2e/daemon-running-work-probe.unit.test.ts": 311, - "tests/e2e/direct-ssh-snapshot-parking.unit.test.ts": 2238, - "tests/e2e/e2e-worker-env-isolation.unit.test.ts": 84, - "tests/e2e/folder-upgrade-identity-persistence.unit.test.ts": 1410, - "tests/e2e/github-work-item-search-burst.unit.test.ts": 1152, - "tests/e2e/global-teardown.unit.test.ts": 136, - "tests/e2e/helpers/alt-screen-frame.unit.test.ts": 110, - "tests/e2e/helpers/client-hosted-browser-fixture.unit.test.ts": 753, - "tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts": 50, - "tests/e2e/helpers/electron-home-isolation.unit.test.ts": 37, - "tests/e2e/helpers/electron-launch-args.unit.test.ts": 52, - "tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts": 2464, - "tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 60, - "tests/e2e/helpers/fake-agent-command-override.unit.test.ts": 35, - "tests/e2e/helpers/fake-agent-paste-end-scanner.unit.test.ts": 79, - "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 57, - "tests/e2e/helpers/golden-source-control.unit.test.ts": 640, - "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 59, - "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 30, - "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 82, - "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 67, - "tests/e2e/helpers/persisted-profile-state.unit.test.ts": 356, - "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 328, - "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 46, - "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 107, + "src/shared/worktree/retired-name-registry.test.ts": 306, + "src/shared/worktree/sort-order-update.test.ts": 42, + "src/shared/worktree/visibility-sources.test.ts": 65, + "src/shared/ws-outbound-backpressure-queue.test.ts": 79, + "src/shared/wsl-login-shell-command.test.ts": 105, + "src/shared/wsl-paths.test.ts": 64, + "src/shared/zcode-missing-tui.test.ts": 56, + "src/shared/zcode-orchestration-contract.test.ts": 161, + "src/shared/zod-salvage-absence.test.ts": 208, + "src/wsl-guest/agent-hook-canonical-status.test.ts": 2544, + "src/wsl-guest/agent-hook-envelope-publication.test.ts": 385, + "src/wsl-guest/agent-hook-integration.test.ts": 1405, + "src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts": 540, + "src/wsl-guest/agent-hook-opencode-tui-identity.test.ts": 638, + "src/wsl-guest/agent-hook-server-claude-owed-notification-expiry.test.ts": 700, + "src/wsl-guest/agent-hook-server-codex-subagent-transcript.test.ts": 1475, + "src/wsl-guest/agent-hook-server-codex-turn-interruption.test.ts": 1828, + "src/wsl-guest/agent-hook-server.test.ts": 1582, + "src/wsl-guest/dispatcher-bulk-admission.test.ts": 415, + "src/wsl-guest/dispatcher-bulk-failed-admission.test.ts": 262, + "src/wsl-guest/dispatcher-bulk-retirement-admission.test.ts": 3089, + "src/wsl-guest/dispatcher-capacity-degradation.test.ts": 314, + "src/wsl-guest/dispatcher-client-close-cause.test.ts": 257, + "src/wsl-guest/dispatcher-client-writer.test.ts": 86, + "src/wsl-guest/dispatcher-frame-guard-regressions.test.ts": 280, + "src/wsl-guest/dispatcher-json-payload.test.ts": 343, + "src/wsl-guest/dispatcher-notification-ownership.test.ts": 218, + "src/wsl-guest/dispatcher-per-connection-state-baseline.test.ts": 266, + "src/wsl-guest/dispatcher-rpc-cancel-id-coercion.test.ts": 245, + "src/wsl-guest/dispatcher-silent-client-reaper.test.ts": 279, + "src/wsl-guest/dispatcher-structured-error.test.ts": 275, + "src/wsl-guest/dispatcher-timeout.test.ts": 247, + "src/wsl-guest/dispatcher-writer-admission.test.ts": 111, + "src/wsl-guest/dispatcher.test.ts": 414, + "src/wsl-guest/legacy-relay-publication-ledger.test.ts": 50, + "src/wsl-guest/omp-config-root.test.ts": 177, + "src/wsl-guest/opencode-startup-prompt-install.test.ts": 139, + "src/wsl-guest/plugin-overlay-env.test.ts": 98, + "src/wsl-guest/plugin-overlay.test.ts": 164, + "src/wsl-guest/plugin-source-limit.test.ts": 46, + "src/wsl-guest/preflight-handler.test.ts": 84, + "src/wsl-guest/protocol-backpressure.test.ts": 109, + "src/wsl-guest/protocol-handshake.test.ts": 59, + "src/wsl-guest/protocol-json-payload.test.ts": 143, + "src/wsl-guest/relay-agent-presence.test.ts": 398, + "src/wsl-guest/relay-command-env.test.ts": 77, + "src/wsl-guest/relay-hot-path-operation-counts.test.ts": 279, + "src/wsl-guest/wsl-agent-hook-relay.test.ts": 454, + "src/wsl-guest/wsl-hook-fs-bridge.test.ts": 80, + "src/wsl-guest/wsl-install-plugins-handler.test.ts": 147, + "tests/e2e/accumulated-workspace-state-builder.unit.test.ts": 120, + "tests/e2e/alternate-screen-fixture-script.unit.test.ts": 60, + "tests/e2e/codex-child-approval-activity-row.unit.test.ts": 4279, + "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 14396, + "tests/e2e/cursor-quota-transport.unit.test.ts": 1102, + "tests/e2e/daemon-running-work-probe.unit.test.ts": 315, + "tests/e2e/direct-ssh-snapshot-parking.unit.test.ts": 2079, + "tests/e2e/folder-upgrade-identity-persistence.unit.test.ts": 2238, + "tests/e2e/github-work-item-search-burst.unit.test.ts": 1180, + "tests/e2e/global-setup-cli-artifact.unit.test.ts": 55, + "tests/e2e/global-teardown.unit.test.ts": 124, + "tests/e2e/helpers/alt-screen-frame.unit.test.ts": 96, + "tests/e2e/helpers/client-hosted-browser-fixture.unit.test.ts": 752, + "tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts": 49, + "tests/e2e/helpers/electron-home-isolation.unit.test.ts": 51, + "tests/e2e/helpers/electron-launch-args.unit.test.ts": 57, + "tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts": 75, + "tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 69, + "tests/e2e/helpers/fake-agent-command-override.unit.test.ts": 66, + "tests/e2e/helpers/fake-agent-paste-end-scanner.unit.test.ts": 68, + "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 59, + "tests/e2e/helpers/golden-source-control.unit.test.ts": 645, + "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 56, + "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 45, + "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 59, + "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 179, + "tests/e2e/helpers/persisted-profile-state.unit.test.ts": 470, + "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 421, + "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 65, + "tests/e2e/helpers/remote-typing-load-script.unit.test.ts": 276, + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 117, "tests/e2e/helpers/streaming-terminal-cleanup.unit.test.ts": 51, - "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 5055, - "tests/e2e/host-guest-paint-retention-remote-viewer.unit.test.ts": 3075, - "tests/e2e/omp-child-session-recovery.unit.test.ts": 1630, - "tests/e2e/omp-retired-host-renderer.unit.test.ts": 2215, - "tests/e2e/omp-risk-remote-review.unit.test.ts": 816, - "tests/e2e/orca-restart-navigation.unit.test.ts": 622, + "tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts": 64, + "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 7476, + "tests/e2e/host-guest-paint-retention-remote-viewer.unit.test.ts": 5043, + "tests/e2e/native-chat-ssh-delivery.unit.test.ts": 5842, + "tests/e2e/omp-child-session-recovery.unit.test.ts": 1983, + "tests/e2e/omp-retired-host-renderer.unit.test.ts": 1794, + "tests/e2e/omp-risk-remote-review.unit.test.ts": 965, + "tests/e2e/orca-restart-navigation.unit.test.ts": 537, "tests/e2e/orchestration-run-pagination.unit.test.ts": 52, - "tests/e2e/paced-terminal-typing.unit.test.ts": 656, - "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts": 2375, - "tests/e2e/remote-agent-completion-authority.unit.test.ts": 1540, - "tests/e2e/remote-terminal-tab-retirement.unit.test.ts": 4050, - "tests/e2e/restored-terminal-input-readiness.unit.test.ts": 57, - "tests/e2e/runtime-graph-publication-probe.unit.test.ts": 40, - "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts": 3808, - "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 775, - "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 1914, - "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts": 287, - "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 53, - "tests/e2e/structured-agent-session-read-owner.unit.test.ts": 739, - "tests/e2e/structured-chat-owner-status-activation.unit.test.ts": 1707, - "tests/e2e/structured-native-chat-routing-authority.unit.test.ts": 214, - "tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts": 2194, - "tests/e2e/terminal-binding-crash-hydration.unit.test.ts": 920, - "tests/e2e/terminal-foreground-confirmation.unit.test.ts": 196, - "tests/e2e/terminal-perf-presentation.unit.test.ts": 44, - "tests/e2e/terminal-probe-input-sequence.unit.test.ts": 53, - "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts": 53, - "tests/e2e/terminal-split-activation-latency-report.unit.test.ts": 68, - "tests/tools/omp-close-lifecycle.test.mjs": 255, - "tests/tools/omp-config-root-local-shell.test.mjs": 196, - "tests/tools/omp-config-root-relay-shell.test.mjs": 169, - "tests/tools/omp-relay-close-lifecycle.test.mjs": 329, - "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 126, - "tests/tools/relay-bench/region-probe-replay.test.mjs": 134, - "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 94, - "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 52, - "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 134, - "tests/tools/win-update-e2e/app-driver.test.mjs": 671, - "tests/tools/win-update-e2e/app-shutdown.test.mjs": 656, - "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 46, - "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 89, - "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 32 + "tests/e2e/paced-terminal-typing.unit.test.ts": 475, + "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts": 4522, + "tests/e2e/remote-agent-completion-authority.unit.test.ts": 1490, + "tests/e2e/remote-terminal-input-delivery-model.unit.test.ts": 18232, + "tests/e2e/remote-terminal-tab-retirement.unit.test.ts": 5251, + "tests/e2e/restored-terminal-input-readiness.unit.test.ts": 71, + "tests/e2e/runtime-graph-publication-probe.unit.test.ts": 65, + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts": 5357, + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 1142, + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 5281, + "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts": 362, + "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 64, + "tests/e2e/structured-agent-session-read-owner.unit.test.ts": 2988, + "tests/e2e/structured-attention-explicit-read.unit.test.ts": 13645, + "tests/e2e/structured-attention-headless-delivery.unit.test.ts": 259, + "tests/e2e/structured-attention-host-read-retirement.unit.test.ts": 3238, + "tests/e2e/structured-attention-prompt-relay.unit.test.ts": 17392, + "tests/e2e/structured-attention-read-owner-lifecycle.unit.test.ts": 17537, + "tests/e2e/structured-attention-read-retirement.unit.test.ts": 13824, + "tests/e2e/structured-attention-relay-settlement.unit.test.ts": 19420, + "tests/e2e/structured-chat-owner-status-activation.unit.test.ts": 3376, + "tests/e2e/structured-native-chat-routing-authority.unit.test.ts": 1170, + "tests/e2e/structured-session-reopened-tab-close.unit.test.ts": 25072, + "tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts": 2142, + "tests/e2e/terminal-account-restart.unit.test.ts": 2678, + "tests/e2e/terminal-binding-crash-hydration.unit.test.ts": 1477, + "tests/e2e/terminal-foreground-confirmation.unit.test.ts": 477, + "tests/e2e/terminal-layout-parity-snapshot.unit.test.ts": 91, + "tests/e2e/terminal-perf-presentation.unit.test.ts": 64, + "tests/e2e/terminal-probe-input-sequence.unit.test.ts": 42, + "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts": 64, + "tests/e2e/terminal-split-activation-latency-report.unit.test.ts": 70, + "tests/tools/omp-close-lifecycle.test.mjs": 283, + "tests/tools/omp-config-root-local-shell.test.mjs": 249, + "tests/tools/omp-config-root-relay-shell.test.mjs": 162, + "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 114, + "tests/tools/relay-bench/region-probe-replay.test.mjs": 84, + "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 102, + "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 65, + "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 89, + "tests/tools/win-update-e2e/app-driver.test.mjs": 541, + "tests/tools/win-update-e2e/app-shutdown.test.mjs": 644, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 59, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 253, + "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 57 } }, "e2e": { @@ -9885,7 +11917,6 @@ "tests/e2e/editor-tab-selection-restore.spec.ts": 28900, "tests/e2e/electron-home-isolation.spec.ts": 12800, "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts": 26800, - "tests/e2e/ephemeral-vm-provisioned-root.spec.ts": 60000, "tests/e2e/feature-wall.spec.ts": 101800, "tests/e2e/file-explorer-watch-refresh.spec.ts": 17700, "tests/e2e/file-open.spec.ts": 60000, diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs index 766550679c71..7182f58f17a8 100644 --- a/config/scripts/ci-unit-dependency-graph.mjs +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -16,7 +16,7 @@ const EXTENSIONS = [ '/index.js' ] const INDIRECT_INPUT = - /\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/ + /\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker|createRequire|getBuiltinModule)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/ function localPath(file, specifier) { if (specifier.startsWith('.')) { @@ -45,6 +45,9 @@ export function buildUnitDependencyGraph(sources, workspace = { resolve: () => n opaque.add(file) } for (const imported of ts.preProcessFile(source, true, true).importedFiles) { + if (imported.fileName === 'module' || imported.fileName === 'node:module') { + opaque.add(file) + } const workspaceSource = workspace.resolve(imported.fileName) const path = workspaceSource?.file ?? localPath(file, imported.fileName) if (path === null) { diff --git a/config/scripts/ci-unit-dependency-graph.test.mjs b/config/scripts/ci-unit-dependency-graph.test.mjs index 96a53192b77a..3859d72949ee 100644 --- a/config/scripts/ci-unit-dependency-graph.test.mjs +++ b/config/scripts/ci-unit-dependency-graph.test.mjs @@ -86,3 +86,58 @@ it.each(['@orca/unknown', '@orca/process-host/private'])( ).toThrow('No public orca-source export') } ) + +it.each([ + [ + 'an assigned native loader', + 'src/main/windows-native-registry.ts', + "import { createRequire } from 'node:module'; const requireFromMain = createRequire(__filename); export const loadWindowsNativeRegistry = () => requireFromMain('@orca/windows-registry')", + 'src/main/windows-registry-addon.test.ts', + "import { loadWindowsNativeRegistry } from './windows-native-registry'" + ], + [ + 'a direct loader with a runtime serializer path', + 'src/main/daemon/serialize-grid-roundtrip.ts', + "import { createRequire } from 'node:module'; export const loadOldSerializer = (path) => createRequire(import.meta.url)(path)", + 'src/main/daemon/serialize-grid.differential.fuzz.test.ts', + "import { loadOldSerializer } from './serialize-grid-roundtrip'" + ], + [ + 'a module namespace whose computed loader escapes through an object', + 'src/main/stored-native-loader.ts', + "import * as hostModule from 'node:module'; const native = hostModule; const load = native['create' + 'Require'](__filename); const api = { load }; export const open = (path) => api.load(path)", + 'src/main/stored-native-loader.test.ts', + "import { open } from './stored-native-loader'" + ], + [ + 'an optional builtin loader', + 'src/main/sqlite/sync-database.ts', + "export const isSqliteAvailable = () => process.getBuiltinModule?.('node:sqlite')", + 'src/main/sqlite/sync-database-portability.test.ts', + "import { isSqliteAvailable } from './sync-database'" + ], + [ + 'an assigned builtin loader', + 'src/main/sqlite/assigned-reader.ts', + "const getBuiltin = process.getBuiltinModule; export const sqlite = () => getBuiltin('node:sqlite')", + 'src/main/sqlite/assigned-reader.test.ts', + "import { sqlite } from './assigned-reader'" + ] +])( + 'keeps real test consumers of %s when another covered source changes', + (_label, owner, source, consumer, testSource) => { + const graph = collectUnitDependencyGraph( + fixture({ + [owner]: source, + [consumer]: testSource, + 'src/selected-input.ts': 'export const value = 1', + 'src/selected-input.test.ts': "import { value } from './selected-input'" + }) + ) + const tests = [...graph.files].filter((file) => file.endsWith('.test.ts')).sort() + expect(selectUnitFiles(tests, ['src/selected-input.ts'], graph)).toMatchObject({ + full: false, + files: [consumer, 'src/selected-input.test.ts'].sort() + }) + } +) diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 3e0cdb67cd8f..3821d25e5605 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -27,7 +27,6 @@ export const UNIT_EXCLUDE = [ 'src/main/terminal-history-fish-session.node-pty.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', - 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts', 'src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts', 'src/shared/fish-query-reply-child-stdin.node-pty.test.ts', diff --git a/config/scripts/hermes-run-correlation-benchmark.mjs b/config/scripts/hermes-run-correlation-benchmark.mjs index 364774577cf7..f392136b5cf5 100644 --- a/config/scripts/hermes-run-correlation-benchmark.mjs +++ b/config/scripts/hermes-run-correlation-benchmark.mjs @@ -17,11 +17,8 @@ const key = (seconds) => .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' + for (const host of ['native']) { + const entry = 'src/main/automations/hermes-cron-run-content.ts' const readers = [] for (const mode of baselineDirectory ? ['baseline', 'current'] : ['current']) { const bundle = join(fixture, `${host}-${mode}.cjs`) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 964de9620bc9..b7ec782b42bd 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -92,9 +92,15 @@ "auto.components.GitHubItemDialog.dc1ca081a8": { "ko": "열림" }, + "auto.components.GitHubItemDialog.e15a8b77ef": { + "ko": "이 검사에는 인라인 세부정보가 없습니다." + }, "auto.components.GitHubItemDialog.e31651a224": { "ko": "실패한 체크 다시 실행" }, + "auto.components.GitHubItemDialog.e45324fbed": { + "ko": "검사 세부정보를 로드하지 못했습니다." + }, "auto.components.GitHubItemDialog.e463ec935f": { "ko": "체크 재실행 요청됨" }, @@ -281,6 +287,9 @@ "auto.components.PullRequestPage.6885c619e7": { "ko": "repos 경로가 없으면 답글을 달 수 없습니다." }, + "auto.components.PullRequestPage.6b1d5ee3e4": { + "ko": "이 검사에는 인라인 세부정보가 없습니다." + }, "auto.components.PullRequestPage.710e47aa06": { "ko": "PR이 다시 열렸습니다" }, @@ -329,6 +338,9 @@ "auto.components.PullRequestPage.ddfd42f460": { "ko": "agent를 시작하기 전에 프롬프트를 검토하세요." }, + "auto.components.PullRequestPage.e04c027d98": { + "ko": "검사 세부정보를 로드하지 못했습니다." + }, "auto.components.PullRequestPage.e1f3641bfd": { "ko": "소스 브랜치" }, @@ -1793,6 +1805,9 @@ "auto.components.right.sidebar.GitHistoryPanel.9289ba0cb9": { "ko": "refs란 무엇인가요?" }, + "auto.components.right.sidebar.GitHubPRStackMap.316039b5db": { + "ko": "검사 결과 대기 중" + }, "auto.components.right.sidebar.PortsPanel.0f1d8cd324": { "ko": "바인딩" }, @@ -1922,6 +1937,9 @@ "auto.components.right.sidebar.gitlab.mr.merge.state.49ac4fec10": { "ko": "검사 실패" }, + "auto.components.right.sidebar.gitlab.mr.merge.state.bf628700f6": { + "ko": "파이프라인이 성공해야 합니다" + }, "auto.components.right.sidebar.index.8bc2bbc3a0": { "ko": "탐색기" }, @@ -5003,6 +5021,9 @@ "auto.web.WebConnect.4a4c017be1": { "ko": "엔드포인트:" }, + "checksPanel.unlinked.description": { + "ko": "이 워크스페이스에서는 PR #{{number}}이(가) 숨겨져 있습니다. 검사와 리뷰 세부정보를 복원하려면 다시 연결하세요." + }, "components.native-chat.approval.allow": { "ko": "허용" }, diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index f8a19e61a8bb..ab7e68a89c95 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -86,7 +86,7 @@ it('avoids extraction for unrelated CI, documentation, and native changes', () = expect( affectsLocalizationExtraction([ '.github/workflows/e2e.yml', - 'config/scripts/run-ssh-docker-e2e.mjs', + 'config/scripts/run-terminal-scale-perf-e2e.mjs', 'docs/reference/ci-runner-efficiency.md', 'native/windows-registry/src/addon.cc' ]) diff --git a/config/scripts/node-pty-console-list-agent-patch.test.mjs b/config/scripts/node-pty-console-list-agent-patch.test.mjs deleted file mode 100644 index 07e97c548324..000000000000 --- a/config/scripts/node-pty-console-list-agent-patch.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { createRequire } from 'node:module' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { - assertPatchedNodePtyConsoleListAgent, - patchNodePtyConsoleListAgent -} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs') -const projectDir = resolve(import.meta.dirname, '..', '..') -const cleanupDirs = [] - -afterEach(() => { - for (const dir of cleanupDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('Windows SSH relay node-pty console-list patch', () => { - it('installs and verifies the fallback idempotently', () => { - const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource()) - - patchNodePtyConsoleListAgent(fixture.root) - const once = readFileSync(fixture.agentPath, 'utf8') - expect(once).toContain('consoleProcessList = [shellPid];') - expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false) - expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow() - - patchNodePtyConsoleListAgent(fixture.root) - expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once) - }) - - it('refuses a different package version or unexpected agent source', () => { - const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource()) - expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0') - - const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`) - expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty') - - const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource()) - patchNodePtyConsoleListAgent(tamperedPatch.root) - writeFileSync( - tamperedPatch.agentPath, - `${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift` - ) - expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed') - }) -}) - -function writeNodePtyFixture(version, agentSource) { - const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-')) - cleanupDirs.push(root) - const nodePtyDir = join(root, 'node_modules', 'node-pty') - const libDir = join(nodePtyDir, 'lib') - const agentPath = join(libDir, 'conpty_console_list_agent.js') - mkdirSync(libDir, { recursive: true }) - writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) - writeFileSync(agentPath, agentSource) - return { root, agentPath } -} - -function publishedAgentSource() { - return [ - '"use strict";', - '/**', - ' * Copyright (c) 2019, Microsoft Corporation (MIT License).', - ' *', - ' * This module fetches the console process list for a particular PID. It must be', - ' * called from a different process (child_process.fork) as there can only be a', - ' * single console attached to a process.', - ' */', - 'Object.defineProperty(exports, "__esModule", { value: true });', - 'var utils_1 = require("./utils");', - "var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;", - 'var shellPid = parseInt(process.argv[2], 10);', - 'var consoleProcessList = getConsoleProcessList(shellPid);', - 'process.send({ consoleProcessList: consoleProcessList });', - 'process.exit(0);', - '//# sourceMappingURL=conpty_console_list_agent.js.map' - ].join('\n') -} diff --git a/config/scripts/node-pty-master-cloexec-patch.test.mjs b/config/scripts/node-pty-master-cloexec-patch.test.mjs deleted file mode 100644 index e323c5d61c85..000000000000 --- a/config/scripts/node-pty-master-cloexec-patch.test.mjs +++ /dev/null @@ -1,333 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { - SKIP_MARKER_FILENAME, - applyNodePtyMasterCloexecPatch, - assertPatchedNodePtyMasterCloexecSource, - patchNodePtyMasterCloexecSource, - revertNodePtyMasterCloexecSource -} = require('../relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs') - -// Byte-exact src/unix/pty.cc from the npm tarball the relay installs. The patch is keyed by its -// sha256, so a fixture that drifted from what npm ships would make every assertion below vacuous. -const STOCK_SOURCE = readFileSync( - resolve(import.meta.dirname, '__fixtures__', 'node-pty-1.1.0-unix-pty.cc'), - 'utf8' -) -const projectDir = resolve(import.meta.dirname, '..', '..') -const cleanupDirs = [] - -afterEach(() => { - for (const dir of cleanupDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('SSH relay node-pty pty fd-leak patch', () => { - it('adds the forkpty close-on-exec call and reverts to the published bytes', () => { - const fixture = writeRelayFixture() - - expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(true) - const patched = readFileSync(fixture.sourcePath, 'utf8') - expect(patched).toContain('pty_cloexec(int fd)') - expect(patched).toContain('if (pty_cloexec(master) == -1)') - expect(() => assertPatchedNodePtyMasterCloexecSource(fixture.root)).not.toThrow() - - expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(false) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(patched) - - expect(revertNodePtyMasterCloexecSource(fixture.root)).toBe(true) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('rewrites the Apple branch, which is the only one macOS executes', () => { - const fixture = writeRelayFixture() - patchNodePtyMasterCloexecSource(fixture.root) - const patched = readFileSync(fixture.sourcePath, 'utf8') - - // Stock's cleanup never runs: the first posix_openpt() already returns >= 2, so the loop - // breaks with count == 0 -- and where it does run it closes low_fds[count], never low_fds[0]. - expect(STOCK_SOURCE).toContain('for (; count > 0; count--) {') - expect(patched).not.toContain('for (; count > 0; count--) {') - expect(patched).toContain('int low_fds[3] = {-1, -1, -1};') - expect(patched).toContain('for (size_t i = 0; i <= count && i < 3; i++) {') - - // `default:` sits in the `#else` arm of PtyFork's `#if defined(__APPLE__)`, so marking only - // the forkpty call site left the master macOS actually opens unmarked. - expect(patched).toContain( - ' if (pty_cloexec(master) == -1) {\n' + - ' throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");\n' + - ' }\n#else\n' - ) - }) - - it('refuses a different node-pty version or an unrecognized source', () => { - const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' }) - expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0') - - const drifted = writeRelayFixture({ - source: `${STOCK_SOURCE}\n// drift\n` - }) - expect(() => patchNodePtyMasterCloexecSource(drifted.root)).toThrow('unexpected node-pty') - - const tampered = writeRelayFixture() - patchNodePtyMasterCloexecSource(tampered.root) - writeFileSync(tampered.sourcePath, `${readFileSync(tampered.sourcePath, 'utf8')}\n// drift\n`) - expect(() => assertPatchedNodePtyMasterCloexecSource(tampered.root)).toThrow('not installed') - }) - - it('keeps the rebuilt addon once a later child no longer inherits the master', () => { - const fixture = writeRelayFixture() - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => { - calls.push('rebuild') - writeBuild(fixture, 'patched-build') - }, - verify: () => 'isolated' - }) - - expect(status).toBe('patched') - expect(calls).toEqual(['rebuild']) - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') - expect(readFileSync(fixture.sourcePath, 'utf8')).not.toBe(STOCK_SOURCE) - expect(existsSync(fixture.backupDir)).toBe(false) - expect(existsSync(fixture.skipMarkerPath)).toBe(false) - }) - - it('keeps a rebuilt addon whose flag /proc could not confirm', () => { - const fixture = writeRelayFixture() - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => writeBuild(fixture, 'patched-build'), - verify: () => 'unverified' - }) - - expect(status).toBe('patched-unverified') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') - }) - - it('restores the working build when the compile fails, and never retries it', () => { - const fixture = writeRelayFixture() - const calls = [] - - const failed = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => { - calls.push('rebuild') - throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') - }, - verify: () => 'isolated' - }) - - expect(failed).toContain('failed:') - expect(failed).toContain('no C++ toolchain') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - expect(existsSync(fixture.backupDir)).toBe(false) - expect(existsSync(fixture.skipMarkerPath)).toBe(true) - - // Bounded, not backed off: a relay directory gets one compile attempt, ever. - const again = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - expect(again).toBe('skipped:earlier-attempt-failed') - expect(calls).toEqual(['rebuild']) - }) - - it('restores the working build when the rebuilt addon still leaks the master', () => { - const fixture = writeRelayFixture() - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => writeBuild(fixture, 'still-leaky-build'), - verify: () => { - throw new Error('rebuilt node-pty still leaks the pty master into later children') - } - }) - - expect(status).toContain('still leaks') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('never compiles on a platform with no pty fds to leak', () => { - const fixture = writeRelayFixture() - const calls = [] - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'win32', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - expect(status).toBe('skipped:unsupported-platform') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('compiles a macOS install out from under its shipped prebuild', () => { - // macOS has no build/ at all: node-pty runs `prebuilds/darwin-`, built from the leaky - // source. Moving `prebuilds` aside is what both arms the rollback and makes node-pty's own - // install script fall through from "prebuild found" to node-gyp. - const fixture = writeRelayFixture({ platform: 'darwin' }) - const prebuildsPresentDuringRebuild = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'darwin', - arch: fixture.arch, - rebuild: () => { - prebuildsPresentDuringRebuild.push(existsSync(fixture.prebuildsDir)) - writeCompiledBuild(fixture, 'patched-build') - }, - verify: () => 'isolated' - }) - - expect(status).toBe('patched') - expect(prebuildsPresentDuringRebuild).toEqual([false]) - expect(readFileSync(fixture.compiledPath, 'utf8')).toBe('patched-build') - // The published tree must hold no unpatched binary: node-pty's loader checks build/Release - // first, but falls back to a prebuild if that ever fails to load. - expect(existsSync(fixture.prebuildsDir)).toBe(false) - expect(existsSync(fixture.backupDir)).toBe(false) - }) - - it('restores the macOS prebuild when the first compile fails', () => { - // A macOS host has no toolchain guarantee at all, so this is the common failure, not the rare - // one -- and the relay has to come back on the prebuild exactly as it was installed. - const fixture = writeRelayFixture({ platform: 'darwin' }) - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'darwin', - arch: fixture.arch, - rebuild: () => { - writeCompiledBuild(fixture, 'half-built') - throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') - }, - verify: () => 'isolated' - }) - - expect(status).toContain('failed:') - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') - expect(existsSync(fixture.compiledPath)).toBe(false) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - expect(existsSync(fixture.skipMarkerPath)).toBe(true) - }) - - it('will not rebuild a macOS install that has no prebuild to fall back on', () => { - const fixture = writeRelayFixture({ platform: 'darwin', build: false }) - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'darwin', - arch: fixture.arch, - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - - expect(status).toBe('skipped:no-prebuild') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('leaves an already patched install alone', () => { - const fixture = writeRelayFixture() - patchNodePtyMasterCloexecSource(fixture.root) - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - - expect(status).toBe('already-patched') - expect(calls).toEqual([]) - }) - - it('will not rebuild an install that has no compiled addon to fall back on', () => { - const fixture = writeRelayFixture({ build: false }) - const calls = [] - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - - expect(status).toBe('skipped:no-compiled-build') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - }) - - it('discards a backup stranded by an interrupted rebuild', () => { - const fixture = writeRelayFixture() - mkdirSync(fixture.backupDir, { recursive: true }) - writeFileSync(join(fixture.backupDir, 'pty.node'), 'stranded-build') - - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform: 'linux', - rebuild: () => writeBuild(fixture, 'patched-build'), - verify: () => 'isolated' - }) - - expect(status).toBe('patched') - expect(existsSync(fixture.backupDir)).toBe(false) - expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') - }) -}) - -/** - * `buildPath` is the working build the patch has to be able to fall back on, which differs by - * platform: Linux compiles into build/Release at install time, macOS runs a shipped prebuild and - * has no build/ at all. `compiledPath` is where the rebuild writes on either. - */ -function writeRelayFixture({ - version = '1.1.0', - source = STOCK_SOURCE, - build = true, - platform = 'linux', - arch = 'arm64' -} = {}) { - const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-')) - cleanupDirs.push(root) - const nodePtyDir = join(root, 'node_modules', 'node-pty') - const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc') - const compiledPath = join(nodePtyDir, 'build', 'Release', 'pty.node') - const prebuildsDir = join(nodePtyDir, 'prebuilds') - mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true }) - writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) - writeFileSync(sourcePath, source) - const fixture = { - root, - arch, - sourcePath, - compiledPath, - prebuildsDir, - buildPath: - platform === 'darwin' ? join(prebuildsDir, `darwin-${arch}`, 'pty.node') : compiledPath, - backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'), - skipMarkerPath: join(root, SKIP_MARKER_FILENAME) - } - if (build) { - writeBuild(fixture, 'stock-build') - } - return fixture -} - -function writeBuild(fixture, contents) { - mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true }) - writeFileSync(fixture.buildPath, contents) -} - -function writeCompiledBuild(fixture, contents) { - mkdirSync(resolve(fixture.compiledPath, '..'), { recursive: true }) - writeFileSync(fixture.compiledPath, contents) -} diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs deleted file mode 100644 index 26428153c0b0..000000000000 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ /dev/null @@ -1,364 +0,0 @@ -// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with -// `config/patches/node-pty@1.1.0.patch`. pnpm patches do not cross the SSH boundary, so a relay runs -// the tree `npm install` put there, and every terminal on a Windows SSH host leaked one File handle -// for the life of the relay process. -// -// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- the placement -// the desktop patch uses -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a -// new Process +1/terminal); releasing it after the console-list fork and the native kill is flat. -// -// Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop -// site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where -// upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/codex-pty-rate-limit-probe.ts`) does omit the option and so does run this -// hunk, but no user-visible pane does. The divergence pinned below is about which -// branch each host runs for terminals -- not about a regression in the panes users open. -import { createHash } from 'node:crypto' -import { createRequire } from 'node:module' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { - assertPatchedNodePtyWindowsTeardown, - patchNodePtyWindowsTeardown -} = require('../relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs') -const projectDir = resolve(import.meta.dirname, '..', '..') -const cleanupDirs = [] - -const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js', 'windowsConoutConnection.js'] - -/** The hunks config/patches/node-pty@1.1.0.patch adds to the installed desktop tree. */ -const DESKTOP_HUNKS = { - 'windowsPtyAgent.js': [ - [ - [ - ' this._inSocket.readable = false;', - ' // The non-DLL path previously only flipped `readable`, leaving the', - ' // conin PipeWrap alive until the host exited (#947).', - ' this._inSocket.destroy();', - ' this._outSocket.readable = false;', - '' - ].join('\n'), - [ - ' this._inSocket.readable = false;', - ' this._outSocket.readable = false;', - '' - ].join('\n') - ], - // The useConptyDll branch, which only the DESKTOP runs -- the relay takes the - // non-DLL branch above, where the dispose is already unconditional. Listed here - // so un-applying still yields published; the relay asset needs no counterpart. - [ - [ - ' // Orca: dispose unconditionally, as the non-DLL branch above does.', - " // Waiting for another 'data' event leaks the conout worker on every", - ' // self-exiting shell, because no more data ever arrives (F24).', - ' this._conoutSocketWorker.dispose();', - '' - ].join('\n'), - [ - " this._outSocket.on('data', function () {", - ' _this._conoutSocketWorker.dispose();', - ' });', - '' - ].join('\n') - ] - ], - 'windowsTerminal.js': [ - [ - ' // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.', - null - ], - [' // A ConPTY input-pipe error must retire only this terminal.', null] - ] -} - -function desktopPath(file) { - return join(projectDir, 'node_modules', 'node-pty', 'lib', file) -} - -afterEach(() => { - for (const dir of cleanupDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('Windows SSH relay node-pty ConPTY teardown patch', () => { - // Why reconstruct rather than vendor upstream: the installed tree IS the published file plus the - // desktop's hunks, so un-applying them yields upstream exactly -- and pinning that against this - // asset's own hashes is what fails loudly if either side of the pair moves. - it('takes the desktop error listeners verbatim', () => { - const fixture = writeNodePtyFixture('1.1.0') - patchNodePtyWindowsTeardown(fixture.root) - - expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe( - readFileSync(desktopPath('windowsTerminal.js'), 'utf8') - ) - }) - - // The one hunk that must NOT match the desktop patch, and the reason is measured, not stylistic: - // on the branch a relay runs, releasing conin before `_getConsoleProcessList()` forks aborts - // teardown partway. Desktop terminal panes take the other branch, so no pane is affected either - // way; what this guards is a patch sync putting the early placement onto the relay's branch. - it('releases conin after the console-list fork, unlike the desktop patch placement', () => { - const fixture = writeNodePtyFixture('1.1.0') - patchNodePtyWindowsTeardown(fixture.root) - const patched = readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'), 'utf8') - - const branch = patched.slice( - patched.indexOf('if (!this._useConptyDll) {'), - patched.indexOf('else {', patched.indexOf('if (!this._useConptyDll) {')) - ) - expect(branch).toContain('this._inSocket.destroy();') - expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan( - branch.indexOf('this._conoutSocketWorker.dispose();') - ) - expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan( - branch.indexOf('this._getConsoleProcessList()') - ) - // Pinned so a future "sync the relay asset to config/patches" cannot copy the early placement - // onto the relay's branch, where it costs +2 File and +1 Process per terminal. - expect(patched).not.toBe(readFileSync(desktopPath('windowsPtyAgent.js'), 'utf8')) - }) - - it('installs and verifies idempotently', () => { - const fixture = writeNodePtyFixture('1.1.0') - - patchNodePtyWindowsTeardown(fixture.root) - const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8')) - for (const file of PATCHED_FILES) { - expect(existsSync(`${join(fixture.libDir, file)}.orca-patch-${process.pid}`)).toBe(false) - } - expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() - - patchNodePtyWindowsTeardown(fixture.root) - expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual( - once - ) - }) - - it('refuses a different package version or unexpected source', () => { - const wrongVersion = writeNodePtyFixture('1.2.0-beta.11') - expect(() => patchNodePtyWindowsTeardown(wrongVersion.root)).toThrow('expected 1.1.0') - - for (const file of PATCHED_FILES) { - const drifted = writeNodePtyFixture('1.1.0') - const path = join(drifted.libDir, file) - writeFileSync(path, `${readFileSync(path, 'utf8')}\n// drift`) - expect(() => patchNodePtyWindowsTeardown(drifted.root)).toThrow('unexpected node-pty') - } - }) - - it('refuses a half-applied tree, so one file cannot pass for both', () => { - for (const file of PATCHED_FILES) { - const partial = writeNodePtyFixture('1.1.0') - const fixture = writeNodePtyFixture('1.1.0') - patchNodePtyWindowsTeardown(fixture.root) - writeFileSync(join(partial.libDir, file), readFileSync(join(fixture.libDir, file), 'utf8')) - expect(() => assertPatchedNodePtyWindowsTeardown(partial.root)).toThrow('is not installed') - } - }) - - it.each([ - '3060c6514a8e9e3285f91b9b549930e7d25d59d4cf7e1ed3a25b9a680dd1ded5', - '598755ee75307d041a72cd7c7c4e12ae4a4bbf35eee19da67b42b61fdae0d4d6', - '5dfeb1dda46645e1d77964ad4d07072b34f0a897dfc7e785137cc39e755b4641' - ])( - 'retires the known %s relay entrypoint without changing legacy input release', - (terminalHash) => { - const fixture = writePreviousNodePtyFixture(terminalHash) - patchNodePtyWindowsTeardown(fixture.root) - expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() - expect( - createHash('sha256') - .update(readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'))) - .digest('hex') - ).toBe('1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280') - expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe( - readFileSync(desktopPath('windowsTerminal.js'), 'utf8') - ) - expect(readFileSync(join(fixture.libDir, 'windowsConoutConnection.js'), 'utf8')).toBe( - readFileSync(desktopPath('windowsConoutConnection.js'), 'utf8') - ) - const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8')) - patchNodePtyWindowsTeardown(fixture.root) - expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual( - once - ) - } - ) - - it('finishes every interrupted entrypoint-first downgrade', () => { - const asset = readFileSync( - join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), - 'utf8' - ) - const expectedOrder = ['windowsTerminal.js', 'windowsPtyAgent.js', 'windowsConoutConnection.js'] - const expectedHashes = [ - '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', - '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280', - '1440f70908fb1f55911ac8e936a1230f68a9c00c03096fcef6788eac6aad9d62' - ] - for (const abortAfter of [1, 2, 3]) { - const fixture = writePreviousNodePtyFixture() - const renamed = [] - const actualFs = require('node:fs') - const interrupted = loadPatchTargets(asset, (name) => - name === 'node:fs' - ? { - ...actualFs, - renameSync(from, to) { - actualFs.renameSync(from, to) - renamed.push(expectedOrder.find((file) => join(fixture.libDir, file) === to)) - if (renamed.length === abortAfter && abortAfter < 3) { - throw new Error('interrupted after actual rename') - } - } - } - : require(name) - ) - if (abortAfter < 3) { - expect(() => interrupted.patchNodePtyWindowsTeardown(fixture.root)).toThrow( - 'interrupted after actual rename' - ) - } else { - interrupted.patchNodePtyWindowsTeardown(fixture.root) - } - expect(renamed).toEqual(expectedOrder.slice(0, abortAfter)) - for (const [index, file] of expectedOrder.entries()) { - const actualHash = createHash('sha256') - .update(readFileSync(join(fixture.libDir, file))) - .digest('hex') - if (index < abortAfter) { - expect(actualHash).toBe(expectedHashes[index]) - } else { - expect(actualHash).not.toBe(expectedHashes[index]) - } - } - patchNodePtyWindowsTeardown(fixture.root) - expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() - for (const [index, file] of expectedOrder.entries()) { - expect( - createHash('sha256') - .update(readFileSync(join(fixture.libDir, file))) - .digest('hex') - ).toBe(expectedHashes[index]) - } - for (const file of ['windowsTerminal.js', 'windowsConoutConnection.js']) { - expect(readFileSync(join(fixture.libDir, file), 'utf8')).toBe( - readFileSync(desktopPath(file), 'utf8') - ) - } - } - }) - - it('refuses unknown downgrade source before changing any file', () => { - for (const file of PATCHED_FILES) { - const fixture = writePreviousNodePtyFixture() - const path = join(fixture.libDir, file) - writeFileSync(path, `${readFileSync(path, 'utf8')}\n// unknown`) - const before = PATCHED_FILES.map((entry) => readFileSync(join(fixture.libDir, entry), 'utf8')) - expect(() => patchNodePtyWindowsTeardown(fixture.root)).toThrow('unexpected node-pty') - expect( - PATCHED_FILES.map((entry) => readFileSync(join(fixture.libDir, entry), 'utf8')) - ).toEqual(before) - } - }) -}) - -/** A published node-pty tree, rebuilt by un-applying the desktop hunks from the installed one. */ -function writeNodePtyFixture(version) { - const root = mkdtempSync(join(projectDir, '.node-pty-teardown-patch-test-')) - cleanupDirs.push(root) - const libDir = join(root, 'node_modules', 'node-pty', 'lib') - mkdirSync(libDir, { recursive: true }) - writeFileSync(join(root, 'node_modules', 'node-pty', 'package.json'), JSON.stringify({ version })) - for (const file of PATCHED_FILES) { - const desktop = readFileSync(desktopPath(file), 'utf8') - for (const [marker] of DESKTOP_HUNKS[file] || []) { - expect(desktop).toContain(marker) - } - writeFileSync(join(libDir, file), unapplyDesktopHunks(file, desktop)) - } - return { root, libDir } -} - -/** - * Reverse of the published-to-desktop transform. - * - * `windowsTerminal.js` is taken verbatim from the desktop, so the asset's own replacement table is - * the transform and reversing it is exact. `windowsPtyAgent.js` deliberately diverges, so its - * published form is rebuilt from the desktop hunk instead -- which is also what makes this file the - * place that notices if the desktop hunk itself ever moves. - */ -function unapplyDesktopHunks(file, desktop) { - if (file === 'windowsPtyAgent.js') { - let published = desktop - for (const [patched, original] of DESKTOP_HUNKS[file]) { - expect(published.split(patched).length - 1).toBe(1) - published = published.replace(patched, original) - } - return published - } - const asset = readFileSync( - join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), - 'utf8' - ) - const { PATCH_TARGETS } = loadPatchTargets(asset) - const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file) - expect(target).toBeDefined() - let published = desktop - for (const [from, to] of target.replacements.toReversed()) { - expect(published.split(to).length - 1).toBe(1) - published = published.replace(to, from) - } - return published -} - -function writePreviousNodePtyFixture( - terminalHash = '5dfeb1dda46645e1d77964ad4d07072b34f0a897dfc7e785137cc39e755b4641' -) { - const fixture = writeNodePtyFixture('1.1.0') - const asset = readFileSync( - join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), - 'utf8' - ) - const { PREVIOUS_PATCH_TARGETS } = loadPatchTargets(asset) - for (const target of PREVIOUS_PATCH_TARGETS) { - const path = join(fixture.libDir, ...target.relativePath.slice(1)) - let source = readFileSync(path, 'utf8') - for (const [from, to] of target.replacements) { - expect(source.split(from).length - 1).toBe(1) - source = source.replace(from, to) - } - let expectedHash = target.patchedSha256 - if (target.relativePath.at(-1) === 'windowsTerminal.js' && terminalHash !== expectedHash) { - const variant = target.additionalPreviousVariants.find( - (entry) => entry.sha256 === terminalHash - ) - expect(variant).toBeDefined() - for (const [from, to] of variant.replacements.toReversed()) { - expect(source.split(to).length - 1).toBe(1) - source = source.replace(to, from) - } - expectedHash = terminalHash - } - expect(createHash('sha256').update(source).digest('hex')).toBe(expectedHash) - writeFileSync(path, source) - } - return fixture -} - -function loadPatchTargets(assetSource, controlledRequire = require) { - const module = { exports: {} } - const factory = new Function( - 'module', - 'exports', - 'require', - `${assetSource}\nmodule.exports.PATCH_TARGETS = PATCH_TARGETS; module.exports.PREVIOUS_PATCH_TARGETS = PREVIOUS_PATCH_TARGETS` - ) - factory(module, module.exports, controlledRequire) - return module.exports -} diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index ffaf8f1dce66..feb4a950378e 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -14,6 +14,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-relay-addon.mjs', + 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/run-node-server-tests.mjs', 'config/vitest.config.ts', 'config/scripts/happy-dom-offscreen-canvas.ts', @@ -144,7 +145,7 @@ function isSourceUnitTest(file) { export async function classifyNodeServerChanges( changedFiles, collect = collectNodeServerInputs, - { deferGraph = false } = {} + { deferGraph = false, modifiedFiles } = {} ) { if (changedFiles.length === 0) { return { shouldRun: true, reason: 'No complete changed-file evidence' } @@ -164,8 +165,16 @@ export async function classifyNodeServerChanges( } try { const inputs = await collect() - const matched = changedFiles.find((file) => inputs.has(file)) + const matchedFiles = changedFiles.filter((file) => inputs.has(file)) + const matched = matchedFiles[0] + const modifiedOnly = + Array.isArray(modifiedFiles) && + new Set(changedFiles).size === changedFiles.length && + new Set(modifiedFiles).size === modifiedFiles.length && + modifiedFiles.length === changedFiles.length && + changedFiles.every((file) => modifiedFiles.includes(file)) return { + ...(modifiedOnly ? { graphResolved: true, matchedFiles } : {}), shouldRun: Boolean(matched), reason: matched ? `Runtime or test dependency changed: ${matched}` @@ -182,8 +191,16 @@ export async function classifyNodeServerChanges( if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) + const modifiedArgument = process.argv.indexOf('--modified-paths') + const modifiedFiles = + modifiedArgument === -1 + ? undefined + : readFileSync(process.argv[modifiedArgument + 1], 'utf8') + .split('\0') + .filter(Boolean) const result = await classifyNodeServerChanges(changedFiles, collectNodeServerInputs, { - deferGraph: process.argv.includes('--defer-graph') + deferGraph: process.argv.includes('--defer-graph'), + modifiedFiles }) console.log(result.reason) const policy = nodeServerQualification(changedFiles, result, { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index d8460d811dee..9bfbe8e82c5d 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -12,7 +12,7 @@ import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { runProcessSync } from '@orca/process-host' -import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs' +import { NODE_SERVER_RUNNERS, nodeServerQualification } from './node-server-qualification.mjs' const temporaryDirs = [] afterEach(() => { @@ -337,6 +337,132 @@ it.each([ } }) +const registry = 'config/scripts/vitest-node-runtime-files.mjs' +const unrelatedRelayUnit = 'src/main/ssh/unrelated-context.test.ts' + +function qualificationTree(extra = {}) { + return moduleTree({ + 'entry.ts': `import './${registry}'; throw Error('never execute')`, + [registry]: 'export const NODE_RUNTIME_INCLUDE = []', + [unrelatedRelayUnit]: 'throw Error("never execute unrelated test")', + ...extra + }) +} + +it('keeps a Node harness smoke without widening it for an unrelated modified SSH unit', async () => { + const root = qualificationTree() + writeFileSync(join(root, registry), 'export const NODE_RUNTIME_INCLUDE = ["new.unit.test.ts"]') + const changed = [registry, unrelatedRelayUnit] + const scope = await classifyNodeServerChanges( + changed, + () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }), + { modifiedFiles: changed } + ) + expect(scope.shouldRun).toBe(true) + expect(nodeServerQualification(changed, scope)).toEqual({ + qualification: false, + runners: ['ubuntu-22.04'] + }) + expect(nodeServerQualification(changed, scope, { fullQualification: true })).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) +}) + +it.each([ + ['src/main/daemon/daemon-authenticated-client-activity.test.ts', NODE_SERVER_RUNNERS, true], + [ + 'src/main/daemon/daemon-endpoint-windows.test.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ] +])('retains daemon platform coverage: %s', async (daemon, runners, qualification) => { + const root = qualificationTree({ [daemon]: 'throw Error("unselected")' }) + const diff = [registry, daemon, unrelatedRelayUnit] + const g = await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) + expect(g.has(registry)).toBe(true) + expect(g.has(daemon)).toBe(false) + const s = await classifyNodeServerChanges(diff, async () => g, { modifiedFiles: diff }) + expect(s.shouldRun).toBe(true) + expect(nodeServerQualification(diff, s)).toEqual({ qualification, runners }) +}) + +it('keeps every host when the modified SSH production module is an actual consumer', async () => { + const sshModule = 'src/main/ssh/agent-status-store.ts' + const root = qualificationTree({ + 'entry.ts': `import './${registry}'; import './${sshModule}'; throw Error('never execute')`, + [sshModule]: 'export const store = 1' + }) + const changed = [registry, sshModule, unrelatedRelayUnit] + const scope = await classifyNodeServerChanges( + changed, + () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }), + { modifiedFiles: changed } + ) + expect(scope.shouldRun).toBe(true) + expect(nodeServerQualification(changed, scope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) +}) + +it.each(['added', 'deleted', 'renamed', 'unknown', 'duplicate', 'foreign'])( + 'retains complete-path qualification when modification evidence is %s', + async (status) => { + const root = qualificationTree() + const changed = [registry, unrelatedRelayUnit] + let modifiedFiles = [registry] + if (status === 'deleted' || status === 'renamed') { + rmSync(join(root, unrelatedRelayUnit)) + } + if (status === 'renamed') { + const renamed = 'src/main/ssh/renamed-context.test.ts' + writeFileSync(join(root, renamed), 'throw Error("never execute renamed test")') + changed.push(renamed) + } + if (status === 'duplicate') { + modifiedFiles = [registry, registry] + } + if (status === 'foreign') { + modifiedFiles = [registry, 'src/main/ssh/foreign.test.ts'] + } + const scope = await classifyNodeServerChanges( + changed, + () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }), + status === 'unknown' ? {} : { modifiedFiles } + ) + expect(scope.shouldRun).toBe(true) + expect(nodeServerQualification(changed, scope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) + } +) + +it('retains protected server tests and broken imports despite complete modification evidence', async () => { + const protectedTest = 'src/main/sqlite/example.test.ts' + const root = qualificationTree({ [protectedTest]: 'export const value = 1' }) + const changed = [registry, protectedTest, unrelatedRelayUnit] + const collect = () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) + const protectedScope = await classifyNodeServerChanges(changed, collect, { + modifiedFiles: changed + }) + expect(nodeServerQualification(changed, protectedScope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) + writeFileSync(join(root, 'entry.ts'), `import './${registry}'; import './missing'`) + const brokenChanged = [registry, unrelatedRelayUnit] + const brokenScope = await classifyNodeServerChanges(brokenChanged, collect, { + modifiedFiles: brokenChanged + }) + expect(brokenScope.graphUnavailable).toBe(true) + expect(nodeServerQualification(brokenChanged, brokenScope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) +}) + describe('the actual Bun build and profile-test dependency graph', () => { let inputs beforeAll(async () => { diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index e9cc5f4bd39d..f63b7a9bd6fe 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -17,7 +17,7 @@ const BUILD_PREFIXES = [ '.github/actions/prepare-orcad-prebuilds/' ] // A remote target's OS does not identify the client platform that builds its commands. -const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/', 'src/wsl-guest/'] +const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/wsl-guest/'] const PLATFORM_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', @@ -37,7 +37,25 @@ export function nodeServerQualification(changedFiles, scope, { fullQualification const selected = new Set(['ubuntu-22.04']) let qualification = false let full = fullQualification || changedFiles.length === 0 || scope.graphUnavailable === true - for (const file of changedFiles) { + const matched = scope.matchedFiles + const qualificationFiles = + scope.graphResolved === true && + scope.graphUnavailable !== true && + scope.shouldRun === true && + Array.isArray(matched) && + matched.length > 0 && + new Set(matched).size === matched.length && + matched.every((file) => typeof file === 'string' && changedFiles.includes(file)) + ? [ + ...new Set([ + ...matched, + ...changedFiles.filter((file) => + PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) + ) + ]) + ] + : changedFiles + for (const file of qualificationFiles) { // Build policy and native sources can change every slot, even with a platform in the name. if ( !file.includes('/') || diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 018d565e2b1a..c320cc367b02 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -39,7 +39,6 @@ it.each([ 'src/main/sqlite/database.ts', 'src/main/orcad/entry.ts', 'src/main/daemon/entry.ts', - 'src/relay/index.ts', 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/orcad-prebuild-slot-contents.mjs', 'src/shared/node-runtime-pin.ts' @@ -124,7 +123,6 @@ it.each([ 'src/main/ssh/orcad-remote-node-runtime-windows.ts', 'src/main/ssh/ssh-posix-command-wrapper.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', - 'src/relay/windows-port-scan.ts', 'src/main/runtime/windows-firewall-remote-scope.ts', 'src/shared/remote-windows-path.ts' ])('qualifies every client platform for a remote execution input: %s', (file) => { diff --git a/config/scripts/orcad-e2e-routing.test.mjs b/config/scripts/orcad-e2e-routing.test.mjs index 066cc2ee6ca1..018eda14a755 100644 --- a/config/scripts/orcad-e2e-routing.test.mjs +++ b/config/scripts/orcad-e2e-routing.test.mjs @@ -6,6 +6,7 @@ import { classifyE2eJobs, ORCAD_AUTO_CONVERT_E2E_SPEC, ORCAD_IDLE_EXIT_E2E_SPEC, + ORCAD_JOURNEY_E2E_SPECS, ORCAD_OPEN_IN_OWNER_E2E_SPEC, ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, WINDOWS_MISSING_APPDATA_E2E_SPEC @@ -137,3 +138,28 @@ it('routes managed target-owner guards and menus to the real launch regression', ) ).toBe(true) }) + +it('runs the managed-host journey specs in their own job when routed', () => { + const job = jobs['orcad-journeys-docker'] + expect(job.needs).toEqual(['build', 'prepare-native-cache']) + for (const spec of ORCAD_JOURNEY_E2E_SPECS) { + expectRouted( + [ + 'tests/e2e/helpers/orcad-managed-workspace.ts', + 'src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts', + 'src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts', + 'src/renderer/src/runtime/runtime-terminal-artifact-read.ts' + ], + spec + ) + expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) + expect( + job.steps.some((step) => step.run?.includes(spec)), + spec + ).toBe(true) + expect(classifyE2eJobs(JSON.stringify([spec])), spec).toEqual({ + e2e_run_changed: false, + e2e_needs_build: true + }) + } +}) diff --git a/config/scripts/orcad-windows-cli-launcher.mjs b/config/scripts/orcad-windows-cli-launcher.mjs new file mode 100644 index 000000000000..14339045317e --- /dev/null +++ b/config/scripts/orcad-windows-cli-launcher.mjs @@ -0,0 +1,63 @@ +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { runProcessSync } from '@orca/process-host' +import { ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME } from '../../src/shared/orcad-artifacts.ts' + +const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( + './windows-pe-machine.cjs' +) + +export function orcadWindowsCliLauncherSource(root, arch) { + return join(root, '.build', 'windows-cli-launcher', arch, 'orca.exe') +} + +/** A Windows slot's `orca` command; a Windows build host compiles a missing one. */ +export function stageOrcadWindowsCliLauncher( + root, + outputDir, + target, + host = { platform: process.platform }, + compile = compileOrcadWindowsCliLauncher +) { + if (!target.startsWith('win32-')) { + return + } + const arch = target.slice('win32-'.length) + const source = orcadWindowsCliLauncherSource(root, arch) + if (!existsSync(source)) { + if (host.platform !== 'win32') { + throw new Error( + `Orcad ${target} requires the native CLI launcher. On Windows, run: ` + + `node config/scripts/build-windows-cli-launcher.mjs --arch ${arch} --output ${source}` + ) + } + compile(root, arch, source) + } + const machine = readPeMachine(source) + if (machine !== PE_MACHINE[arch]) { + throw new Error(`Orcad ${target} CLI launcher has ${describePeMachine(machine)}`) + } + const destination = join(outputDir, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(source, destination) +} + +function compileOrcadWindowsCliLauncher(root, arch, output) { + const result = runProcessSync({ + program: process.execPath, + args: [ + join(root, 'config/scripts/build-windows-cli-launcher.mjs'), + '--arch', + arch, + '--output', + output + ], + cwd: root, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`Windows CLI launcher build for ${arch} failed with exit ${result.code}`) + } +} diff --git a/config/scripts/orcad-windows-cli-launcher.test.mjs b/config/scripts/orcad-windows-cli-launcher.test.mjs new file mode 100644 index 000000000000..5e12c5597e18 --- /dev/null +++ b/config/scripts/orcad-windows-cli-launcher.test.mjs @@ -0,0 +1,66 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + orcadWindowsCliLauncherSource, + stageOrcadWindowsCliLauncher +} from './orcad-windows-cli-launcher.mjs' + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function peImage(machine) { + const bytes = Buffer.alloc(0x90) + bytes.write('MZ') + bytes.writeUInt32LE(0x80, 0x3c) + bytes.write('PE\0\0', 0x80) + bytes.writeUInt16LE(machine, 0x84) + return bytes +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orcad-cli-launcher-')) + roots.push(root) + const output = join(root, 'output') + mkdirSync(output) + return { root, output } +} + +function prepare(root, arch, machine) { + const source = orcadWindowsCliLauncherSource(root, arch) + mkdirSync(dirname(source), { recursive: true }) + writeFileSync(source, peImage(machine)) + return source +} + +it('stages the prepared launcher for the requested machine at the slot command path', () => { + const { root, output } = fixture() + const source = prepare(root, 'arm64', 0xaa64) + stageOrcadWindowsCliLauncher(root, output, 'win32-arm64', { platform: 'linux' }) + expect(readFileSync(join(output, 'bin', 'orca.exe'))).toEqual(readFileSync(source)) +}) + +it('rejects a launcher built for the wrong machine', () => { + const { root, output } = fixture() + prepare(root, 'x64', 0xaa64) + expect(() => stageOrcadWindowsCliLauncher(root, output, 'win32-x64')).toThrow('machine 0xaa64') +}) + +it('refuses a missing launcher off Windows and compiles it on Windows', () => { + const { root, output } = fixture() + expect(() => + stageOrcadWindowsCliLauncher(root, output, 'win32-x64', { platform: 'darwin' }) + ).toThrow('native CLI launcher') + const compile = vi.fn((_root, arch, path) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, peImage(arch === 'x64' ? 0x8664 : 0xaa64)) + }) + stageOrcadWindowsCliLauncher(root, output, 'win32-x64', { platform: 'win32' }, compile) + expect(compile).toHaveBeenCalledWith(root, 'x64', orcadWindowsCliLauncherSource(root, 'x64')) + expect(readFileSync(join(output, 'bin', 'orca.exe'))).toEqual(peImage(0x8664)) +}) + +it('ignores POSIX targets', () => { + expect(() => stageOrcadWindowsCliLauncher('absent', 'absent', 'linux-x64-glibc')).not.toThrow() +}) diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index 696aa26ed05e..6b5fb57c11ec 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -205,9 +205,7 @@ describe('SSH Windows consumers of qualified server slots', () => { expect(template).toBeLessThan(hosts) expect(sshSteps[template].env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS).toBe('${{ matrix.arch }}') expect(sshSteps[template].run).toContain('--require-slots "win32-${{ matrix.arch }}"') - expect(sshSteps[hosts].run).toContain( - "@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')" - ) + expect(sshSteps[hosts].run).toContain("@('orcad-cmd','orcad-powershell')") for (const workflowPaths of [ workflow.on.pull_request.paths, sshWorkflow.on.pull_request.paths diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index abb759c69f1b..5ed417ba2a9f 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -51,7 +51,6 @@ const GIT_COMPAT_PREFIXES = [ 'src/shared/review-head-tracking-ref', 'src/shared/worktree/local-base-branch-fast-forward', 'src/main/git/', - 'src/relay/git-', 'config/scripts/git-binary-compatibility' ] @@ -244,12 +243,10 @@ const CROSS_VERSION_WIRE_PREFIXES = [ ] const MANAGED_HOOK_PREFIXES = [ - 'config/scripts/smoke-managed-hook-runtime-node18', 'config/scripts/smoke-process-host-node18', 'config/scripts/packaged-process-host-fixture', 'src/packages/process-host/', 'config/scripts/build-relay', - 'src/relay/', 'src/wsl-guest/', 'src/shared/agent-hook', 'src/main/agent-hooks/' @@ -360,6 +357,7 @@ const LINUX_PACKAGE_TESTS = [ ] const WINDOWS_PACKAGE_TESTS = [ + 'src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts', ...LINUX_PACKAGE_TESTS, 'config/scripts/rebuild-native-deps.test.mjs', 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', @@ -368,6 +366,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'config/scripts/ensure-native-runtime-job-ownership.test.mjs', 'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs', 'config/scripts/windows-pe-machine.test.mjs', + 'config/scripts/build-windows-cli-launcher.test.mjs', 'config/scripts/script-module-dependencies.test.mjs', 'src/main/windows-registry-addon.test.ts', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', @@ -411,8 +410,6 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', 'src/main/ipc/preflight-provider-command-selection.test.ts', 'src/main/ipc/preflight-runnable-local-cli.test.ts', - 'src/relay/windows-port-scan.win32.test.ts', - 'src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts', 'src/main/ssh/remote-node-runtime-store-windows.test.ts' ] @@ -574,9 +571,12 @@ function isProductBundlePath(file, extraPrefixes) { return matchesPrefix(file, extraPrefixes) } -// This shared fixture is consumed only by unit suites and their placement rig. +// These shared fixtures are consumed only by unit tests. export function isUnitTestSupportSource(file) { - return file === 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' + return ( + file === 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' || + file === 'src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx' + ) } function isTestFile(file) { diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 33667dff6fba..60c6c46248e7 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,20 +1,16 @@ import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' -import { linuxInstallPackageList } from './pr-e2e-linux-packages.test-fixture.mjs' import { existsSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse as parseYaml } from 'yaml' import { hasNativeImeSourceChange, - hasSshSourceChange, PR_E2E_SOURCE_ROUTES, - selectPrE2eSpecs, - SSH_SOURCE_ROUTE_IDS + selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) -const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' @@ -77,152 +73,10 @@ describe('PR E2E gate contract', () => { ).toEqual(['tests/e2e/active-view-restart-restore.spec.ts']) }) - it('maps SSH source edits onto the Docker-backed specs they can break', () => { - // Why: the Docker-SSH specs self-skip without ORCA_E2E_SSH_DOCKER, and the only - // trigger used to be "someone edited a spec" — four pane-restore regressions shipped - // through that hole. Each mapped spec must exist, or the lane runs an empty file list. - const sshSourceAuthorities = [ - 'src/main/ssh/', - 'src/main/providers/ssh-', - 'src/main/ipc/pty', - 'src/relay/', - 'src/wsl-guest/', - 'src/shared/ssh-', - 'src/renderer/src/store/slices/direct-ssh-', - 'src/renderer/src/components/terminal-pane/remote-runtime-' - ] - for (const authority of sshSourceAuthorities) { - expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain( - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' - ) - } - - // Why named files rather than prefixes: these seams are single modules, and a prefix - // here would route their unrelated neighbours. - for (const file of [ - 'src/main/runtime/public-ssh-state.ts', - 'src/renderer/src/startup/ssh-startup-reconnect.ts', - 'src/renderer/src/store/slices/ssh.ts' - ]) { - expect(selectPrE2eSpecs([file]), file).toContain( - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' - ) - } - - const mappedSpecs = [ - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' - ] - for (const spec of mappedSpecs) { - expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec) - expect(existsSync(join(projectDir, spec)), spec).toBe(true) - // Why: a spec that stops reading the flag would silently run without Docker. - if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') { - expect(readFileSync(join(projectDir, spec), 'utf8'), spec).toContain('ORCA_E2E_SSH_DOCKER') - } - } - - expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([]) - - // Why: startup readiness is filtered out of changed-e2e, so listing it is only - // meaningful while it still routes the dedicated Docker lane. - expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toContain( - 'tests/e2e/ssh-startup-exec-readiness.spec.ts' - ) - - // Why: this lane can now pay a Docker image build plus serial SSH specs. - expect(e2eWorkflow.jobs['changed-e2e']['timeout-minutes']).toBeGreaterThanOrEqual(60) - const install = e2eWorkflow.jobs['changed-e2e'].steps.find((step) => - step.name.startsWith('Install native build') - ) - expect(linuxInstallPackageList(install, 'changed-e2e')).toMatch(/(^|\s)openssh-client(\s|$)/) - }) - - it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => { - // Why by name: none of these carry "ssh", so the SSH authorities above never reach them - // — a closed-tab tombstone and a dropped default-tabs marker both shipped through it. - for (const file of [ - 'src/renderer/src/hooks/remote-workspace-session-merge.ts', - 'src/main/ipc/remote-workspace-snapshot-normalization.ts', - 'src/renderer/src/lib/worktree-initial-terminal-seeding.ts', - 'src/renderer/src/lib/worktree-default-terminal-tabs.ts', - 'src/shared/remote-workspace-session-projection.ts', - 'src/renderer/src/components/terminal/initial-terminal.ts' - ]) { - const specs = selectPrE2eSpecs([file]) - expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts') - expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts') - } - - expect( - selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts']) - ).toEqual([]) - expect( - selectPrE2eSpecs([ - 'src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts' - ]) - ).toEqual([]) - }) - - it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => { - // The behavioural half of the invariant, and the part that actually matters: an SSH source - // edit is recognised as one, through the same routes that select the specs. - for (const file of [ - 'src/main/ssh/connection.ts', - 'src/relay/pty-handler.ts', - 'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts', - 'src/renderer/src/hooks/remote-workspace-session-merge.ts', - 'src/main/ipc/remote-workspace-snapshot-normalization.ts' - ]) { - expect(hasSshSourceChange([file]), file).toBe(true) - } - for (const file of [ - 'src/main/git/git-status.ts', - 'src/renderer/src/components/tab-bar/BrowserTab.tsx', - 'src/main/ssh/connection.test.ts' - ]) { - expect(hasSshSourceChange([file]), file).toBe(false) - } - - // Why: the signal must stay derived from the routes. A route id that no longer exists would - // silently narrow it to nothing. - for (const id of SSH_SOURCE_ROUTE_IDS) { - expect( - PR_E2E_SOURCE_ROUTES.map((route) => route.id), - id - ).toContain(id) - } - - // Why text and not structure: a job `if:` is only ever available as a string. The strongest - // available assertion is that the source signal is its own disjunct, so the lane no longer - // depends on a spec name surviving in a route's spec list. - const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if - expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||") - - expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string') - expect(prWorkflow.jobs.code_paths.outputs.ssh_source_changed).toBe( - '${{ steps.e2e_filter.outputs.ssh_source_changed }}' - ) - expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe( - '${{ needs.code_paths.outputs.ssh_source_changed }}' - ) - expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source') - expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED') - }) - it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => { expect(rollbackStep.run).toMatch(/diff-base\.mjs "\$BASE_SHA"[\s\S]*"\$DIFF_BASE" HEAD --/) expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts') expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts') - expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([ - 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' - ]) }) it('routes P0 sentinels from their causal sources', () => { diff --git a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs new file mode 100644 index 000000000000..f0962edff158 --- /dev/null +++ b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { classifyE2eJobs, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs' +import { + PR_E2E_SOURCE_ROUTES, + selectPrE2eSpecs, + shouldRunReusablePrE2e +} from './pr-e2e-source-routing.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') + +const ORACLE_ROUTE = PR_E2E_SOURCE_ROUTES.find((route) => route.id === 'workspace-layout.oracle') +const TAB_STORE_ROUTE = PR_E2E_SOURCE_ROUTES.find( + (route) => route.id === 'workspace-layout.oracle-tab-store' +) + +/** One product file per layout writer family; each alone must run the oracle. */ +const LAYOUT_AUTHORITIES = [ + 'src/shared/workspace-layout/workspace-layout-pane-commands.ts', + 'src/main/persistence/terminal-topology/terminal-topology-commit.ts', + 'src/main/persistence/restoring-sessions/session-owner-fields.ts', + 'src/main/persistence/loading-store/pty-binding-persistence.ts', + 'src/main/runtime/orca-runtime-split-pty-backed-terminal.ts', + 'src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'src/main/runtime/orca-runtime-create-terminal.ts', + 'src/renderer/src/runtime/mobile-session-tab-close.ts', + 'src/renderer/src/runtime/runtime-layout-client.ts', + 'tests/e2e/workspace-layout-oracle-known-on-main.ts', + 'tests/e2e/helpers/workspace-layout-oracle-compare.ts' +] + +describe('workspace layout oracle PR E2E routing', () => { + it('routes each layout authority on its own', () => { + expect(ORACLE_ROUTE, 'the oracle route was renamed or removed').toBeDefined() + for (const file of LAYOUT_AUTHORITIES) { + expect(selectPrE2eSpecs([file]), file).toEqual(ORACLE_ROUTE.specs.toSorted()) + expect(shouldRunReusablePrE2e([file]), file).toBe(true) + } + }) + + it('runs the window and headless oracles, not the SSH lane, for the tab store', () => { + for (const file of [ + 'src/renderer/src/store/slices/tabs.ts', + 'src/renderer/src/store/slices/tabs/tabs-move-actions.ts' + ]) { + expect(selectPrE2eSpecs([file]), file).toEqual(TAB_STORE_ROUTE.specs.toSorted()) + expect(existsSync(join(projectDir, file)), file).toBe(true) + } + }) + + it('sends each oracle spec to the job that can run it', () => { + const specs = ORACLE_ROUTE.specs + // Window spec runs in the general shards; headless runs in its dedicated job. + expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts']) + expect(classifyE2eJobs(JSON.stringify(specs))).toEqual({ + e2e_run_changed: true, + e2e_needs_build: true + }) + }) + + it('keeps every routed spec and authority a real file', () => { + for (const file of [...ORACLE_ROUTE.specs, ...LAYOUT_AUTHORITIES]) { + expect(existsSync(join(projectDir, file)), file).toBe(true) + } + }) + + it('leaves tests, fixtures and unrelated runtime modules alone', () => { + for (const file of [ + 'src/main/persistence/terminal-topology/terminal-topology-commit.test.ts', + 'src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts', + 'src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts', + 'src/main/runtime/runtime-durable-store-fixture.ts', + 'src/main/runtime/orchestration/db/worker-terminal/worker-terminal-store.ts', + 'src/main/runtime/rpc/methods/terminal/terminal-create.ts', + 'src/main/runtime/runtime-rpc-database.ts', + 'src/main/runtime/orca-runtime-files.ts', + 'src/renderer/src/runtime/sync-runtime-graph.ts', + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', + 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts', + 'tests/e2e/workspace-layout-oracle-latency.spec.ts' + ]) { + expect(ORACLE_ROUTE.matches(file) || TAB_STORE_ROUTE.matches(file), file).toBe(false) + } + }) +}) diff --git a/config/scripts/pr-e2e-native-only-routing.test.mjs b/config/scripts/pr-e2e-native-only-routing.test.mjs index b6c3662cd1d6..2fcd7450b701 100644 --- a/config/scripts/pr-e2e-native-only-routing.test.mjs +++ b/config/scripts/pr-e2e-native-only-routing.test.mjs @@ -17,7 +17,6 @@ describe('native-only PR E2E routing', () => { } expect(shouldRunReusablePrE2e([])).toBe(false) for (const spec of [ - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', 'tests/e2e/paired-startup-exec-readiness.spec.ts', 'tests/e2e/terminal-ime-exact-byte.spec.ts', 'tests/e2e/future.spec.ts' diff --git a/config/scripts/pr-e2e-pane-close-routing.test.mjs b/config/scripts/pr-e2e-pane-close-routing.test.mjs index 889eec7c3615..c66c77fb8f1c 100644 --- a/config/scripts/pr-e2e-pane-close-routing.test.mjs +++ b/config/scripts/pr-e2e-pane-close-routing.test.mjs @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { - hasSshSourceChange, PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs, shouldRunReusablePrE2e @@ -57,10 +56,7 @@ describe('pane close and retirement PR E2E routing', () => { } }) - it('leaves the Docker SSH lane and unrelated changes alone', () => { - // The lane costs a Docker relay per run. Close changes reach SSH only through the shared - // provider, which the non-Docker specs above already cover. - expect(hasSshSourceChange(PENDING_PANE_CLOSE_CHANGE)).toBe(false) + it('leaves unrelated changes alone', () => { // Neighbours in the same directories that this route must not claim. for (const file of [ 'src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx', diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 1db0497bb884..6addbe03624a 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -3,6 +3,8 @@ import { pathToFileURL } from 'node:url' import { isUnitTestSupportSource } from './pr-code-change-scope.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) +const isLayoutOracleSource = (file) => + isProductSource(file) && !/\.spec\.ts$|-fixtures?\.ts$/.test(file) // Why config/patches: the xterm fork owns the helper textarea an input method attaches to, so a // patch edit can break composition without touching a file named "ime". @@ -185,13 +187,21 @@ export const PR_E2E_SOURCE_ROUTES = [ )) }, { - id: 'ssh.localhost-agent-hooks', - specs: ['tests/e2e/ssh-localhost.spec.ts'], + // Why: managed SSH hosts are now the only SSH path, so these specs carry its user journeys. + id: 'ssh.orcad-journeys', + specs: [ + 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts', + 'tests/e2e/ssh-orcad-workspace-tools.spec.ts', + 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts' + ], matches: (file) => - isProductSource(file) && - /^src\/(?:relay\/(?:agent-hook|relay-agent-hook-runtime|plugin-overlay)|wsl-guest\/(?:agent-hook|plugin-overlay)|main\/(?:agent-hooks\/|ssh\/ssh-relay-session\.ts$)|shared\/agent-hook)/.test( + /^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|paired-host-terminal|terminal-workspace-root-link)\.ts$/.test( file - ) + ) || + (isProductSource(file) && + /^src\/(?:main\/ssh\/orcad-managed-|main\/ipc\/ssh-port-forward|renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts$|renderer\/src\/runtime\/(?:web-runtime-session|runtime-terminal-artifact-read\.ts$)|renderer\/src\/components\/terminal-pane\/(?:remote-runtime-|terminal-host-(?:workspace-file|file-link-existence)\.ts$))/.test( + file + )) }, { id: 'browser-network.ssh-docker-route', @@ -204,17 +214,6 @@ export const PR_E2E_SOURCE_ROUTES = [ file )) }, - { - // Why the host-connection phase: the route gate waits on it, so a phase change can strand the - // SSH-unavailable card without touching a browser file. - id: 'browser.local-ssh-workspace-route', - specs: ['tests/e2e/local-ssh-browser-routing.spec.ts'], - matches: (file) => - isProductSource(file) && - /^src\/(?:main\/browser\/local-ssh-browser-(?:route|partitions)\.ts|renderer\/src\/(?:components\/browser-pane\/(?:use-ssh-workspace-browser-route\.ts|assemble-chrome\/ssh-routed-browser-page-gate\.tsx)|lib\/worktree-host-connection-phase\.ts))$/.test( - file - ) - }, { id: 'terminal.windows-wsl-launch-and-paste', specs: [ @@ -227,55 +226,6 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, - { - id: 'ephemeral-vm-runtime.rollback-readable-sidecar', - specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'], - matches: (file) => - /^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test( - file - ) - }, - { - id: 'ssh-terminal-source', - specs: [ - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-docker-half-open-link.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts', - 'tests/e2e/ssh-docker-resource-accumulation.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' - ], - // Why the store/startup/shared additions: the SSH-named authorities stop at the main - // process and the pane component, but the reconnect ledgers and retained-payload - // admission that decide whether a pane rebinds live in the renderer store. - matches: (file) => - isProductSource(file) && - /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/wsl-guest\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test( - file - ) - }, - { - // Why a sibling route rather than more paths on ssh-terminal-source: these modules carry - // no "ssh" in their names, and only the two restore specs gate them. Folding them in - // would run the whole SSH terminal list for a tab-tombstone edit. - id: 'ssh-workspace-session-restore', - specs: [ - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts' - ], - matches: (file) => - isProductSource(file) && - !file.endsWith('-test-harness.ts') && - /^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test( - file - ) - }, { id: 'terminal-input.ime-and-synthetic-forwarding', specs: [ @@ -319,7 +269,7 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'], matches: (file) => isProductSource(file) && - /^(?:src\/main\/ipc\/filesystem-(?:list-files|search-file-paths)\.ts|src\/main\/ripgrep\/bundled-ripgrep-path\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain|relay-bundled-ripgrep)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability|bundled-ripgrep)\.ts)$/.test( + /^(?:src\/main\/ipc\/filesystem-(?:list-files|search-file-paths)\.ts|src\/main\/ripgrep\/bundled-ripgrep-path\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability|bundled-ripgrep)\.ts)$/.test( file ) }, @@ -430,6 +380,31 @@ export const PR_E2E_SOURCE_ROUTES = [ /^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test( file ) + }, + { + // Why: the oracle is the only check that reads every view of the stored layout back. Runtime + // matches only the modules that create, move, close, persist or publish layout, not every + // session or terminal file, so the orcad build and Docker SSH lane stay off unrelated work. + id: 'workspace-layout.oracle', + specs: [ + 'tests/e2e/workspace-layout-oracle-headless.spec.ts', + 'tests/e2e/workspace-layout-oracle.spec.ts' + ], + matches: (file) => + isLayoutOracleSource(file) && + /^(?:src\/shared\/workspace-layout\/|src\/main\/persistence\/(?:terminal-topology\/|restoring-sessions\/|loading-store\/pty-binding-)|src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|create-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement)[^/]*\.ts$|tests\/e2e\/(?:helpers\/)?(?:workspace-layout-oracle|headless-layout-oracle|terminal-layout-journeys))/.test( + file + ) + }, + { + // The window's tab store reaches SSH through the same layout writers, so not the SSH lane. + id: 'workspace-layout.oracle-tab-store', + specs: [ + 'tests/e2e/workspace-layout-oracle-headless.spec.ts', + 'tests/e2e/workspace-layout-oracle.spec.ts' + ], + matches: (file) => + isLayoutOracleSource(file) && file.startsWith('src/renderer/src/store/slices/tabs') } ] @@ -446,23 +421,11 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) { return [...specs].sort((left, right) => left.localeCompare(right)) } -/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */ -export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore'] - -// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to -// trigger only because one route happened to list a startup-readiness spec, so pruning that -// spec would have silently retired the lane. Two lists that must agree is how that drifted. -export function hasSshSourceChange(changedPaths) { - return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some( - (route) => changedPaths.some(route.matches) - ) -} - /** Routes whose authorities a real input method can judge, and so require the native IME lane. */ export const NATIVE_IME_SOURCE_ROUTE_IDS = ['terminal-ime.native-input-method'] -// Why derived from the routes, like hasSshSourceChange: the native lane must trigger on IME -// source, not on the native spec surviving in some route's spec list. +// Why derived from the routes: the native lane must trigger on IME source, not on the native +// spec surviving in some route's spec list. export function hasNativeImeSourceChange(changedPaths) { return PR_E2E_SOURCE_ROUTES.filter((route) => NATIVE_IME_SOURCE_ROUTE_IDS.includes(route.id) @@ -470,12 +433,9 @@ export function hasNativeImeSourceChange(changedPaths) { } export function shouldRunReusablePrE2e(changedPaths) { - // Native IME has its own workflow; SSH still runs inside the reusable workflow. - return ( - hasSshSourceChange(changedPaths) || - selectPrE2eSpecs(changedPaths).some( - (spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts' - ) + // Native IME has its own workflow. + return selectPrE2eSpecs(changedPaths).some( + (spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts' ) } @@ -493,9 +453,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) input += chunk } const changedPaths = input.split(/\r?\n/).filter(Boolean) - if (process.argv.includes('--ssh-source')) { - process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`) - } else if (process.argv.includes('--reusable-workflow')) { + if (process.argv.includes('--reusable-workflow')) { process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`) } else if (process.argv.includes('--wsl-source')) { process.stdout.write(`${hasWslSourceChange(changedPaths)}\n`) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index f2936f23438f..d53e09fb3d57 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -7,10 +7,11 @@ import { classifyPrJobs } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const preflight = workflow.jobs.preflight const steps = preflight.steps -const compiler = steps.find((step) => step.run === 'pnpm run typecheck') +const compiler = steps.find((step) => step.id === 'typecheck') +const compilerJoin = steps.findIndex((step) => step.wait === 'typecheck') const plan = steps.find((step) => step.id === 'unit-plan') -it('shares one setup and runs the unchanged compiler after static checks finish', () => { +it('shares one setup and overlaps the compiler only after type-aware checks finish', () => { expect(workflow.jobs.static_analysis).toBeUndefined() expect(workflow.jobs.typecheck).toBeUndefined() expect(preflight.needs).toEqual(['code_paths']) @@ -25,7 +26,11 @@ it('shares one setup and runs the unchanged compiler after static checks finish' expect(install.with['persist-native-cache']).not.toBe('false') } expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) - expect(compiler.background).toBeUndefined() + expect(compiler.background).toBe(true) + expect(compiler.run).toContain('pnpm run typecheck:ci') + const bunSetup = steps.find((step) => step.name === 'Set up Bun for preflight checks') + expect(bunSetup.with['bun-version']).toBe('1.4.3') + expect(bunSetup.if).toBeUndefined() expect(plan.background).toBe(true) expect(plan.run.trim().split('\n')).toEqual([ 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi', @@ -33,16 +38,36 @@ it('shares one setup and runs the unchanged compiler after static checks finish' ]) expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') expect(plan.env.ORCA_UNIT_FULL_SHARD_COUNT).toBe("${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '5' }}") - expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) + expect(steps.indexOf(plan)).toBeLessThan(compilerJoin) expect(steps.indexOf(compiler)).toBeGreaterThan( + steps.findIndex((step) => step.wait === 'changed-code-quality') + ) + expect(steps.indexOf(compiler)).toBeLessThan( + steps.findIndex((step) => step.id === 'localization-extraction') + ) + expect(compilerJoin).toBeGreaterThan( steps.findIndex((step) => step.wait?.includes('localization-extraction')) ) }) +it('keeps the Bun anti-slop scan aligned with the local audit', () => { + const audit = steps.find((step) => step.name === 'Reject low-evidence patterns') + const manifest = JSON.parse(readFileSync('package.json', 'utf8')) + const localCommand = manifest.scripts['audit:anti-slop'].split('&&').at(-1).trim() + expect(audit.run.trim().split('\n')[0]).toBe('node config/scripts/sync-anti-slop-plugin.mjs') + expect( + audit.run + .trim() + .split('\n') + .at(-1) + .replace('bun --smol node_modules/oxlint/bin/oxlint', 'oxlint') + ).toBe(localCommand) +}) + it('requires physical preflight success before publishing shards and admitting consumers', () => { const join = steps.findIndex((step) => step.wait === 'unit-plan') const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7') - expect(join).toBeGreaterThan(steps.indexOf(compiler)) + expect(join).toBeGreaterThan(compilerJoin) expect(upload).toBeGreaterThan(join) expect(steps[upload]['continue-on-error']).toBeUndefined() expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}') @@ -184,7 +209,6 @@ it('pins every foreground and background step to its selected phase', () => { (step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '') ) expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([ - ['Set up Bun for localization checks', staticPhase], ['Reject low-evidence patterns', staticPhase], ['Enforce type-aware code-quality baseline', staticPhase], [ @@ -210,8 +234,6 @@ it('pins every foreground and background step to its selected phase', () => { ['Guard against project-owned .d.ts in preload/shared', staticPhase], ['Check feature wall asset budget', staticPhase], ['Verify macOS entitlements', staticPhase], - ['Cache TypeScript incremental state', typePhase], - ['pnpm run typecheck', typePhase], ['actions/upload-artifact@v7', typePhase] ]) expect( @@ -223,6 +245,7 @@ it('pins every foreground and background step to its selected phase', () => { ['root-lint', `\${{ ${staticPhase} }}`], ['native-code-quality', `\${{ ${staticPhase} }}`], ['changed-code-quality', `\${{ ${staticPhase} }}`], + ['typecheck', `\${{ ${typePhase} }}`], ['localization-extraction', `\${{ ${staticPhase} }}`], ['localization-catalogs', `\${{ ${staticPhase} }}`], ['unit-plan', `\${{ ${typePhase} }}`] @@ -271,7 +294,9 @@ it.each([ expect(runInNewContext(preflight.if, { needs })).toBe( expected.static_analysis || expected.typecheck ) - expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck) + expect(runInNewContext(compiler.env.PREFLIGHT_PHASE_SELECTED.slice(3, -2), { needs })).toBe( + expected.typecheck + ) expect(scope.test).toBe(expected.typecheck) }) diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index d3a11b7469d0..b37fbb921237 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -145,12 +145,7 @@ describe('ready-for-review required check reuse', () => { ) } expect(detector.outputs.should_run).toBe('${{ steps.filter.outputs.should_run }}') - for (const name of [ - 'test_files', - 'ssh_source_changed', - 'native_ime_source_changed', - 'wsl_source_changed' - ]) { + for (const name of ['test_files', 'native_ime_source_changed', 'wsl_source_changed']) { expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`) } expect(detector.steps.find((step) => step.id === 'e2e_filter').if).toBe( diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index 318b74bc7800..21fbdfd452c9 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -3,30 +3,44 @@ import { classifyPrJobs } from './pr-code-change-scope.mjs' import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const harness = 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' +const structuredSessionHarness = + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx' -describe('shared web-session unit support routing', () => { - it('keeps unit checks and lint while excluding packaging for the exact harness', () => { - const result = classifyPrJobs([harness]) - expect(result.should_run).toBe(true) - expect(result.static_analysis).toBe(true) - expect(result.typecheck).toBe(true) - expect(result.test).toBe(true) - expect(result.package).toBe(false) - expect(result.package_windows).toBe(false) - }) +describe('unit support routing', () => { + it.each([harness, structuredSessionHarness])( + 'keeps unit checks and lint while excluding packaging for %s', + (unitHarness) => { + const result = classifyPrJobs([unitHarness]) + expect(result.should_run).toBe(true) + expect(result.static_analysis).toBe(true) + expect(result.typecheck).toBe(true) + expect(result.test).toBe(true) + expect(result.package).toBe(false) + expect(result.package_windows).toBe(false) + } + ) - it('retains packaging for unknown helpers and mixed product changes', () => { - for (const file of [ - 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', - 'src/renderer/src/runtime/web-session-tabs-sync.ts' - ]) { - const result = classifyPrJobs([harness, file]) - expect(result.should_run, file).toBe(true) - expect(result.test, file).toBe(true) - expect(result.package, file).toBe(true) - expect(result.package_windows, file).toBe(true) + it.each([harness, structuredSessionHarness])( + 'retains packaging for unknown helpers and mixed product changes with %s', + (unitHarness) => { + for (const file of [ + 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', + 'src/renderer/src/runtime/web-session-tabs-sync.ts', + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.next-test-harness.tsx', + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx', + 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', + 'src/main/browser/browser-route-tcp-egress.electron.test.ts' + ]) { + const result = classifyPrJobs([unitHarness, file]) + expect(result.should_run, file).toBe(true) + expect(result.test, file).toBe(true) + expect(result.package, file).toBe( + file !== 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts' + ) + expect(result.package_windows, file).toBe(true) + } } - }) + ) it('does not turn the exact unit harness into a two-app E2E', () => { expect(selectPrE2eSpecs([harness])).toEqual([]) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 8c302a4563d6..49c009b04ce4 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -9,7 +9,7 @@ const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', describe('PR workflow parallelism', () => { it('enforces process-host boundaries in the required static-analysis checks', () => { const guard = workflow.jobs.preflight.steps.find((step) => - step.run?.includes('pnpm run check:process-host-imports') + step.run?.includes('bun --bun run check:process-host-imports') ) expect(guard?.if).toBe('!cancelled()') expect(guard?.env.PREFLIGHT_PHASE_SELECTED).toBe( @@ -17,7 +17,7 @@ describe('PR workflow parallelism', () => { ) expect(guard?.background).toBe(true) const typecheckIndex = workflow.jobs.preflight.steps.findIndex( - (step) => step.run === 'pnpm run typecheck' + (step) => step.wait === 'typecheck' ) const waitIndex = workflow.jobs.preflight.steps.findIndex((step) => step.wait === guard.id) expect(waitIndex).toBeGreaterThan(typecheckIndex) diff --git a/config/scripts/preflight-benchmark-command.mjs b/config/scripts/preflight-benchmark-command.mjs new file mode 100644 index 000000000000..da0664cacae1 --- /dev/null +++ b/config/scripts/preflight-benchmark-command.mjs @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { runProcess } from '@orca/process-host' + +export async function measurePreflightCommand({ + program, + args, + label, + root, + output, + env, + timeoutMs = 240_000 +}) { + const resourceFile = path.join(output, `${label}.resources.txt`) + const start = performance.now() + const result = await runProcess({ + program: process.platform === 'linux' ? '/usr/bin/time' : program, + args: process.platform === 'linux' ? ['-v', '-o', resourceFile, program, ...args] : args, + cwd: root, + env: { ...env, LC_ALL: 'C' }, + timeoutMs, + maxOutputBytes: 8 * 1024 * 1024 + }) + if (result.outputTruncated) { + throw new Error(`Benchmark output was truncated: ${label}`) + } + const resources = process.platform === 'linux' ? readFileSync(resourceFile, 'utf8') : '' + const peakRssKiB = + Number(resources.match(/Maximum resident set size \(kbytes\): (\d+)/)?.[1]) || null + return { ...result, durationMs: performance.now() - start, peakRssKiB } +} diff --git a/config/scripts/pty-source-ack-boundary-benchmark.mjs b/config/scripts/pty-source-ack-boundary-benchmark.mjs deleted file mode 100644 index 2792146391bd..000000000000 --- a/config/scripts/pty-source-ack-boundary-benchmark.mjs +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -// Benchmarks relay PTY sent-boundary cleanup across a cumulative ACK drain, using the shipped -// PtySourceSentBoundaries rather than a stub so V8's real Set/array costs are included. -import { performance } from 'node:perf_hooks' -import { createJiti } from 'jiti' - -const BOUNDARY_COUNTS = [1_024, 4_096, 16_384, 65_536] -// Set rehash timing swings run to run, so every row reports a median instead of a single sample. -const REPETITIONS = 3 -const jiti = createJiti(import.meta.url) -const { PtySourceSentBoundaries } = await jiti.import( - '../../src/relay/pty-source-sent-boundaries.ts' -) - -// Pre-change cleanup: rescan every retained boundary on every ACK. -function fullScanDrain(count) { - const boundaries = new Set() - for (let boundary = 0; boundary <= count; boundary += 1) { - boundaries.add(boundary) - } - const started = performance.now() - for (let creditedEndSu = 1; creditedEndSu <= count; creditedEndSu += 1) { - for (const boundary of boundaries) { - if (boundary < creditedEndSu) { - boundaries.delete(boundary) - } - } - } - return performance.now() - started -} - -// Early-break cleanup: stops at the first live boundary, but still rebuilds a Set iterator per ACK. -function earlyBreakDrain(count) { - const boundaries = new Set() - for (let boundary = 0; boundary <= count; boundary += 1) { - boundaries.add(boundary) - } - const started = performance.now() - for (let creditedEndSu = 1; creditedEndSu <= count; creditedEndSu += 1) { - for (const boundary of boundaries) { - if (boundary >= creditedEndSu) { - break - } - boundaries.delete(boundary) - } - } - return performance.now() - started -} - -// Shipped cleanup: a monotone cursor over ascending boundaries, so no boundary is ever revisited. -function cursorDrain(count) { - const boundaries = new PtySourceSentBoundaries(0) - for (let boundary = 1; boundary <= count; boundary += 1) { - boundaries.add(boundary) - } - const started = performance.now() - for (let creditedEndSu = 1; creditedEndSu <= count; creditedEndSu += 1) { - boundaries.dropBelow(creditedEndSu) - } - return performance.now() - started -} - -function medianMs(drain, count) { - const samples = Array.from({ length: REPETITIONS }, () => drain(count)).sort((a, b) => a - b) - return samples[(samples.length - 1) >> 1] -} - -function report(count) { - const fullScanMs = medianMs(fullScanDrain, count) - const earlyBreakMs = medianMs(earlyBreakDrain, count) - const cursorMs = medianMs(cursorDrain, count) - console.log( - `${String(count).padStart(6)} boundaries ` + - `full-scan ${fullScanMs.toFixed(2)}ms ` + - `early-break ${earlyBreakMs.toFixed(2)}ms ` + - `cursor ${cursorMs.toFixed(3)}ms ` + - `(cursor is ${(fullScanMs / cursorMs).toFixed(0)}x faster than full-scan, ` + - `${(earlyBreakMs / cursorMs).toFixed(0)}x faster than early-break)` - ) -} - -// Warm the three drains up first so the smallest row is not dominated by JIT tiering. -fullScanDrain(256) -earlyBreakDrain(256) -cursorDrain(256) - -for (const count of BOUNDARY_COUNTS) { - report(count) -} diff --git a/config/scripts/relay-artifact-manifest.test.mjs b/config/scripts/relay-artifact-manifest.test.mjs deleted file mode 100644 index 5f66ee7136f8..000000000000 --- a/config/scripts/relay-artifact-manifest.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -// Packaged-relay contract: what `build:relay` actually writes to disk. -// -// Asserts against a real build, not the source tree — the unit suites cannot see -// this gap, because the WSL transcript dispatcher runs in-process under vitest -// and never forks. -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { - RELAY_BUILD_PLATFORMS, - RELAY_VERSION_FILENAME, - isWindowsRelayPlatform, - relayArtifactFilenames, - relayOptionalArtifactFilenames -} from '../../src/shared/relay-artifacts.ts' - -const projectDir = resolve(import.meta.dirname, '../..') -// Its own tree: building into out/relay would clobber a developer's build and -// race the suites that read it. -const relayOutDir = mkdtempSync(join(tmpdir(), 'orca-relay-contract-')) - -beforeAll(() => { - // A repeated build must not copy stale companions into the other platform bundles. - for (const platform of RELAY_BUILD_PLATFORMS) { - const outDir = join(relayOutDir, platform) - mkdirSync(outDir, { recursive: true }) - writeFileSync(join(outDir, 'stale-companion.js'), 'throw new Error("stale")') - } - execFileSync('node', [join(projectDir, 'config', 'scripts', 'build-relay.mjs')], { - cwd: projectDir, - stdio: 'pipe', - env: { ...process.env, ORCA_RELAY_OUT_ROOT: relayOutDir } - }) -}, 120_000) - -afterAll(() => { - rmSync(relayOutDir, { recursive: true, force: true }) -}) - -describe('packaged relay artifact manifest', () => { - it.each([...RELAY_BUILD_PLATFORMS])('emits exactly the declared artifacts for %s', (platform) => { - const outDir = join(relayOutDir, platform) - const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) - - for (const filename of expected) { - expect(existsSync(join(outDir, filename)), `${platform}/${filename} missing`).toBe(true) - } - // Exactly, not merely at least: an undeclared artifact ships unhashed and - // unprobed, which is the same gap in the other direction. - const emitted = readdirSync(outDir) - .filter((name) => name !== RELAY_VERSION_FILENAME) - .sort() - const optional = relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)).filter( - (filename) => existsSync(join(outDir, filename)) - ) - expect(emitted).toEqual([...expected, ...optional].sort()) - }) - - it.each([...RELAY_BUILD_PLATFORMS])('hashes every declared artifact for %s', (platform) => { - const outDir = join(relayOutDir, platform) - const hash = createHash('sha256') - const artifacts = [ - ...relayArtifactFilenames(isWindowsRelayPlatform(platform)), - ...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)).filter((filename) => - existsSync(join(outDir, filename)) - ) - ] - for (const filename of artifacts) { - hash.update(readFileSync(join(outDir, filename))) - } - const version = readFileSync(join(outDir, RELAY_VERSION_FILENAME), 'utf8') - - // A companion left out of the hash lets a changed relay reuse an existing - // immutable remote directory, serving a mixed-generation install forever. - expect(version.split('+')[1]).toBe(hash.digest('hex').slice(0, 12)) - }) - - it('ships the WSL transcript helper beside the service that forks it', () => { - for (const platform of RELAY_BUILD_PLATFORMS) { - const outDir = join(relayOutDir, platform) - const service = readFileSync(join(outDir, 'relay-ai-vault-service.js'), 'utf8') - - // The bundled service reaches the fork, so the entry must sit beside it: - // the spawn resolves the child relative to its own bundle directory. - expect(service, `${platform} service no longer forks the helper`).toContain( - 'wsl-transcript-fs-process-entry.js' - ) - expect( - existsSync(join(outDir, 'wsl-transcript-fs-process-entry.js')), - `${platform} forks a helper it does not ship` - ).toBe(true) - } - }) -}) diff --git a/config/scripts/relay-asset-line-ending-pin.test.mjs b/config/scripts/relay-asset-line-ending-pin.test.mjs deleted file mode 100644 index 3384aa9b88a0..000000000000 --- a/config/scripts/relay-asset-line-ending-pin.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { resolve } from 'node:path' -import { RELAY_ARTIFACTS } from '../../src/shared/relay-artifacts.ts' -import { describe, expect, it } from 'vitest' - -/** - * Guard the `.gitattributes` pin that keeps `config/relay-assets` on LF. - * - * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a - * pin a Windows runner checks these out as CRLF. build-relay.mjs copies them - * verbatim into the bundle and hashes them byte-for-byte into `.version`, which - * names the immutable remote relay directory -- so a Windows-built client and a - * mac/Linux-built one disagree on the same release, and one SSH host ends up with - * two relay trees, each paying its own remote native-dep compile. - * - * Measured on v1.4.197: master-cloexec-patch.cjs shipped at 11229 bytes from the - * mac runner and 11547 (= 11229 + 318 lines) from the Windows one. - */ -const projectDir = resolve(import.meta.dirname, '../..') - -function git(args) { - return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' }) -} - -/** `git check-attr -z` emits NUL-separated path/attr/value triples. */ -function eolAttributes(paths) { - const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0') - const found = new Map() - for (let index = 0; index + 2 < fields.length; index += 3) { - found.set(fields[index], fields[index + 2]) - } - return found -} - -/** - * Keyed off the manifest, not a directory: build-relay refuses to emit an - * artifact absent from RELAY_ARTIFACTS, so relocating an asset cannot slip - * past this the way a path glob would. esbuild bundles have no tracked - * source and contribute no hits, so they need no classifying. - */ -function trackedManifestSources() { - const paths = new Set() - for (const { filename } of RELAY_ARTIFACTS) { - const hits = git(['ls-files', '-z', '--', `*/${filename}`]) - .split('\0') - .filter(Boolean) - for (const path of hits) { - paths.add(path) - } - } - return [...paths] -} - -describe('config/relay-assets line-ending pin', () => { - it('pins every tracked relay artifact source to LF', () => { - const assets = trackedManifestSources() - expect(assets.length).toBeGreaterThan(0) - - const attributes = eolAttributes(assets) - const unpinned = assets.filter((path) => attributes.get(path) !== 'lf') - - expect( - unpinned, - 'A relay asset left on the platform default gets CRLF on a Windows runner, ' + - 'which changes the .version hash and splits one release across two remote ' + - 'relay directories. Pin it in .gitattributes.' - ).toEqual([]) - }) - - // Why: the assertion above only sees files that exist today. These fix the - // pattern itself -- broad enough to cover a file added tomorrow, narrow enough - // not to claim neighbours. - it.each([ - ['config/relay-assets/example.cjs', 'lf'], - ['config/relay-assets/nested/deeper/example.cjs', 'lf'], - ['config/relay-assets/example.txt', 'lf'], - ['config/relay-assets-extra/example.cjs', 'unspecified'], - ['vendor/config/relay-assets/example.cjs', 'unspecified'] - ])('resolves %s to eol=%s', (path, expected) => { - expect(eolAttributes([path]).get(path)).toBe(expected) - }) -}) diff --git a/config/scripts/relay-replay-buffer-benchmark.mjs b/config/scripts/relay-replay-buffer-benchmark.mjs deleted file mode 100644 index 31091ede611c..000000000000 --- a/config/scripts/relay-replay-buffer-benchmark.mjs +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -// Benchmark: the relay's per-PTY-chunk replay buffer append (src/relay/pty-handler.ts). -// -// appendReplayBuffer did `buffered += data` then, over the cap, `buffered.slice(-CAP)`. -// Once a PTY has produced CAP bytes -- which a long-lived shell does almost immediately -// -- every subsequent chunk flattened and copied the whole 100 KB window. The append is -// called per raw node-pty emission, before batching, so it is per chunk, not per flush. -// -// The fix reuses RecentPtyOutputBuffer: keep chunks, drop from the head, and defer the -// join to read(), which only attach/adopt/revive call. -// -// Both arms are compared for an identical retained tail before timing. -// -// Run with: node config/scripts/relay-replay-buffer-benchmark.mjs -import { readFileSync } from 'node:fs' -import { performance } from 'node:perf_hooks' - -const ROUNDS = 6 -const SECONDS = Number(process.env.ORCA_REPLAY_BENCH_SECONDS ?? '1') -if (!Number.isFinite(SECONDS) || SECONDS <= 0) { - throw new Error(`ORCA_REPLAY_BENCH_SECONDS must be positive, received ${SECONDS}`) -} - -// Why re-read the sources: the claim is that the relay now appends into a chunk deque -// with the relay's own cap. If either reverts, these numbers stop meaning what they say. -const HANDLER_SOURCE = readFileSync( - new URL('../../src/relay/pty-handler.ts', import.meta.url), - 'utf8' -) -if (!/managed\.buffered\.append\(/.test(HANDLER_SOURCE)) { - throw new Error('relay no longer appends into a chunk deque; this benchmark is stale') -} -const capMatch = HANDLER_SOURCE.match(/REPLAY_BUFFER_MAX = ([\d *]+)/) -if (!capMatch) { - throw new Error('REPLAY_BUFFER_MAX not found; this benchmark is stale') -} -// The regex admits only digits, spaces, and `*`, so the literal is a plain product. -const REPLAY_BUFFER_MAX = capMatch[1] - .split('*') - .map((factor) => Number(factor.trim())) - .reduce((product, factor) => product * factor, 1) -if (!Number.isSafeInteger(REPLAY_BUFFER_MAX) || REPLAY_BUFFER_MAX <= 0) { - throw new Error(`could not read REPLAY_BUFFER_MAX from source, got ${capMatch[1]}`) -} - -// Pre-fix: rolling string, re-sliced once over the cap. -function appendString(state, data) { - if (data.length === 0) { - return state - } - const next = state + data - return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next -} - -// Post-fix: mirrors RecentPtyOutputBuffer's append/read for the relay's options. -class ChunkDeque { - constructor(limit) { - this.chunks = [] - this.headIndex = 0 - this.headOffset = 0 - this.totalLen = 0 - this.limit = limit - } - - append(data) { - if (data.length === 0) { - return - } - if (data.length >= this.limit) { - this.chunks = [data.slice(-this.limit)] - this.headIndex = 0 - this.headOffset = 0 - this.totalLen = this.limit - return - } - this.chunks.push(data) - this.totalLen += data.length - while (this.totalLen > this.limit) { - const headRemaining = this.chunks[this.headIndex].length - this.headOffset - const excess = this.totalLen - this.limit - if (headRemaining <= excess) { - this.chunks[this.headIndex] = '' - this.headIndex += 1 - this.headOffset = 0 - this.totalLen -= headRemaining - } else { - this.headOffset += excess - this.totalLen -= excess - } - } - if (this.headIndex >= 1024) { - this.chunks = this.chunks.slice(this.headIndex) - this.headIndex = 0 - } - } - - read() { - if (this.chunks.length - this.headIndex > 1) { - const retained = this.chunks.slice(this.headIndex) - if (this.headOffset > 0) { - retained[0] = retained[0].slice(this.headOffset) - this.headOffset = 0 - } - this.chunks = [retained.join('')] - this.headIndex = 0 - } else if (this.headOffset > 0) { - this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset) - this.headOffset = 0 - } - return this.chunks[this.headIndex] ?? '' - } -} - -function makeChunks(chunkBytes, chunkCount) { - // Vary content so V8 cannot dedupe or treat the appends as loop-invariant. - return Array.from({ length: chunkCount }, (_value, index) => - `${index}:`.padEnd(chunkBytes, 'abcdefghijklmnopqrstuvwxyz') - ) -} - -// Why pre-saturate: the interesting regime is a PTY that has already filled the window, -// which is where the old form copied 100 KB on literally every chunk. Timing from empty -// would average in a cheap warm-up the real process leaves behind in milliseconds. -function saturate(chunks) { - let stringState = '' - const deque = new ChunkDeque(REPLAY_BUFFER_MAX) - const preload = 'p'.repeat(REPLAY_BUFFER_MAX) - stringState = appendString(stringState, preload) - deque.append(preload) - return { stringState, deque, chunks } -} - -function median(samples) { - const sorted = [...samples].sort((a, b) => a - b) - const mid = sorted.length / 2 - return (sorted[mid - 1] + sorted[mid]) / 2 -} - -function timeString(chunks) { - let state = 'p'.repeat(REPLAY_BUFFER_MAX) - const start = performance.now() - for (const chunk of chunks) { - state = appendString(state, chunk) - } - const elapsed = performance.now() - start - if (state.length !== REPLAY_BUFFER_MAX) { - throw new Error('string arm lost its window') - } - return elapsed -} - -function timeDeque(chunks) { - const deque = new ChunkDeque(REPLAY_BUFFER_MAX) - deque.append('p'.repeat(REPLAY_BUFFER_MAX)) - const start = performance.now() - for (const chunk of chunks) { - deque.append(chunk) - } - const elapsed = performance.now() - start - return elapsed -} - -// Arms alternate which one leads so within-round drift cannot favour either. -function measure(chunks) { - timeString(chunks) - timeDeque(chunks) - const stringSamples = [] - const dequeSamples = [] - for (let round = 0; round < ROUNDS; round += 1) { - if (round % 2 === 0) { - stringSamples.push(timeString(chunks)) - dequeSamples.push(timeDeque(chunks)) - } else { - dequeSamples.push(timeDeque(chunks)) - stringSamples.push(timeString(chunks)) - } - } - return { stringMs: median(stringSamples), dequeMs: median(dequeSamples) } -} - -const pad = (value, width) => String(value).padStart(width) -console.log('Relay PTY replay-buffer append, per second of output. Lower is better.') -console.log( - `cap=${(REPLAY_BUFFER_MAX / 1024).toFixed(0)} KiB rounds=${ROUNDS} (per-arm medians, pre-saturated)` -) -console.log( - `${pad('workload', 30)} ${pad('rolling str', 12)} ${pad('chunk deque', 12)} ${pad('speedup', 9)}` -) - -for (const [label, chunkBytes, chunksPerSecond] of [ - ['interactive shell 64B x200', 64, 200], - ['agent TUI 512B x400', 512, 400], - ['build log 4KiB x256 (1 MiB/s)', 4 * 1024, 256], - ['dump 8KiB x512 (4 MiB/s)', 8 * 1024, 512], - ['firehose 16KiB x1024 (16 MiB/s)', 16 * 1024, 1024] -]) { - const chunks = makeChunks(chunkBytes, Math.round(chunksPerSecond * SECONDS)) - const { stringState, deque } = saturate(chunks) - let stringTail = stringState - for (const chunk of chunks) { - stringTail = appendString(stringTail, chunk) - deque.append(chunk) - } - if (deque.read() !== stringTail) { - throw new Error(`retained tail differs for ${label}`) - } - if (stringTail.length !== REPLAY_BUFFER_MAX) { - throw new Error(`fixture never saturated the window for ${label}`) - } - const { stringMs, dequeMs } = measure(chunks) - console.log( - `${pad(label, 30)} ${pad(`${stringMs.toFixed(3)} ms`, 12)} ${pad(`${dequeMs.toFixed(3)} ms`, 12)} ${pad(`${(stringMs / dequeMs).toFixed(0)}x`, 9)}` - ) -} - -console.log( - "\nThis is per PTY, and the relay runs on the user's SSH host. Reads (attach, adopt,\nrevive) now pay the join instead, but those are rare and were already O(window)." -) diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs deleted file mode 100644 index 1f1f4510afd9..000000000000 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ /dev/null @@ -1,385 +0,0 @@ -import { build } from 'esbuild' -import { spawn } from 'node:child_process' -import { randomBytes } from 'node:crypto' -import { createRequire } from 'node:module' -import { existsSync } from 'node:fs' -import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' - -const WAIT_TIMEOUT_MS = 20_000 -const require = createRequire(import.meta.url) - -// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated -// transport, and #12746 admits pty.data only after a consumer grant. -function writeEndpointCredential(path) { - return writeFile(path, randomBytes(32).toString('base64url'), 'utf8') -} - -function withTimeout(promise, label, stderr) { - return new Promise((resolvePromise, rejectPromise) => { - const timer = setTimeout(() => { - rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`)) - }, WAIT_TIMEOUT_MS) - promise.then( - (value) => { - clearTimeout(timer) - resolvePromise(value) - }, - (error) => { - clearTimeout(timer) - rejectPromise(error) - } - ) - }) -} - -function pollUntil(readValue, label, stderr) { - const deadline = Date.now() + WAIT_TIMEOUT_MS - return new Promise((resolveValue, rejectValue) => { - const poll = async () => { - try { - const value = await readValue() - if (value !== undefined) { - resolveValue(value) - return - } - } catch {} - if (Date.now() >= deadline) { - rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`)) - return - } - setTimeout(poll, 10) - } - void poll() - }) -} - -function waitForExit(proc) { - if (proc.exitCode !== null || proc.signalCode !== null) { - return Promise.resolve() - } - return new Promise((resolveExit) => proc.once('exit', resolveExit)) -} - -async function loadProtocol(bundleDir) { - const outfile = join(bundleDir, 'relay-protocol.cjs') - await build({ - entryPoints: [resolve('src/wsl-guest/protocol.ts')], - bundle: true, - platform: 'node', - format: 'cjs', - outfile, - logLevel: 'silent' - }) - return require(outfile) -} - -function attachProcessStreams(proc) { - let stderr = '' - proc.stderr.on('data', (chunk) => { - stderr = `${stderr}${String(chunk)}`.slice(-8_000) - }) - return { - stderr: () => stderr - } -} - -function waitForStdoutSentinel(proc, protocol, stderr) { - let stdoutBuffer = Buffer.alloc(0) - return withTimeout( - new Promise((resolvePromise, rejectPromise) => { - let settled = false - const onData = (chunk) => { - stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) - const sentinel = Buffer.from(protocol.RELAY_SENTINEL) - const index = stdoutBuffer.indexOf(sentinel) - if (index === -1) { - return - } - settled = true - proc.stdout.off('data', onData) - proc.off('exit', onExit) - resolvePromise(stdoutBuffer.subarray(index + sentinel.length)) - } - const onExit = (code, signal) => { - if (settled) { - return - } - settled = true - proc.stdout.off('data', onData) - rejectPromise( - new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) - ) - } - proc.stdout.on('data', onData) - proc.once('exit', onExit) - }), - 'relay sentinel', - stderr - ) -} - -function createRelayClient(entryPath, args, env, protocol) { - const proc = spawn(process.execPath, [entryPath, ...args], { - cwd: dirname(entryPath), - env, - stdio: ['pipe', 'pipe', 'pipe'] - }) - const streams = attachProcessStreams(proc) - const messages = [] - let nextSequence = 1 - let stdoutBuffer = Buffer.alloc(0) - let ready = false - let resolveReady - const sentinelReceived = new Promise((resolvePromise) => { - resolveReady = resolvePromise - }) - const decoder = new protocol.FrameDecoder((frame) => { - if (frame.type === protocol.MessageType.Regular) { - messages.push(protocol.parseJsonRpcMessage(frame.payload)) - } - }) - proc.stdout.on('data', (chunk) => { - if (ready) { - decoder.feed(chunk) - return - } - stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) - const sentinel = Buffer.from(protocol.RELAY_SENTINEL) - const index = stdoutBuffer.indexOf(sentinel) - if (index === -1) { - return - } - ready = true - resolveReady() - const remainder = stdoutBuffer.subarray(index + sentinel.length) - if (remainder.length > 0) { - decoder.feed(remainder) - } - }) - - const waitForMessage = (startIndex, predicate, label) => - pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) - - const request = async (method, params = {}) => { - const id = nextSequence++ - const startIndex = messages.length - proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) - const response = await waitForMessage( - startIndex, - (message) => message.id === id, - `response to ${method}` - ) - if (response.error) { - throw new Error(`${method} failed: ${response.error.message}`) - } - return response.result - } - - const notify = (method, params = {}) => { - const sequence = nextSequence++ - proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0)) - } - - return { - proc, - request, - notify, - sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr), - messageCount: () => messages.length, - waitForNotification: (startIndex, method, predicate = () => true) => - waitForMessage( - startIndex, - (message) => message.method === method && predicate(message.params ?? {}), - `${method} notification` - ), - stderr: streams.stderr - } -} - -async function stopProcess(proc, stderr, label) { - if (!proc || proc.exitCode !== null || proc.signalCode !== null) { - return - } - proc.kill('SIGTERM') - try { - await withTimeout(waitForExit(proc), `${label} shutdown`, stderr) - } catch { - proc.kill('SIGKILL') - await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr) - } -} - -async function waitForWatcherPid(pidFile, previousPid, stderr) { - return pollUntil( - async () => { - const pid = Number((await readFile(pidFile, 'utf8')).trim()) - if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) { - return undefined - } - // Why: replacement children reuse this exclusive path after fault injection. - await rm(pidFile, { force: true }) - return pid - }, - previousPid ? 'replacement watcher child pid' : 'initial watcher child pid', - stderr - ) -} - -function includesWatchPath(params, targetPath) { - return Array.isArray(params.events) - ? params.events.some((event) => event.absolutePath === targetPath) - : false -} - -async function main() { - const platform = `${process.platform}-${process.arch}` - const relayEntry = resolve('out', 'relay', platform, 'relay.js') - const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js') - if (!existsSync(relayEntry) || !existsSync(watcherEntry)) { - throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`) - } - - let tempRoot - let daemon - let daemonStreams - let relay - try { - tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-')) - const watchRoot = await realpath(tempRoot) - const pidFile = join(tempRoot, 'watcher.pid') - const credentialFile = join(tempRoot, 'endpoint.credential') - const protocol = await loadProtocol(tempRoot) - const socketPath = - process.platform === 'win32' - ? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}` - : join(tempRoot, 'relay.sock') - await writeEndpointCredential(credentialFile) - - // Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer - // session; only an endpoint-credential socket client is admitted for pty.data after #12746. - daemon = spawn( - process.execPath, - [ - relayEntry, - '--detached', - '--grace-time', - '0', - '--sock-path', - socketPath, - '--endpoint-dir', - join(tempRoot, 'agent-hooks'), - '--credential-file', - credentialFile - ], - { - cwd: dirname(relayEntry), - env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile }, - stdio: ['ignore', 'pipe', 'pipe'] - } - ) - daemonStreams = attachProcessStreams(daemon) - await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr) - - relay = createRelayClient( - relayEntry, - ['--connect', '--sock-path', socketPath, '--credential-file', credentialFile], - process.env, - protocol - ) - await relay.sentinelReceived - - // Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens. - const grant = await relay.request('pty.openClient', { - protocolVersion: 1, - clientInstanceId: `relay-watcher-fault-${process.pid}`, - requestedRole: 'session-owner' - }) - if (grant?.role !== 'session-owner') { - throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`) - } - - const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot }) - const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}` - let startIndex = relay.messageCount() - relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` }) - await relay.waitForNotification( - startIndex, - 'pty.data', - (params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker) - ) - - await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid( - pidFile, - undefined, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` - ) - const beforePath = join(watchRoot, 'before.txt') - startIndex = relay.messageCount() - await writeFile(beforePath, 'before') - await relay.waitForNotification(startIndex, 'fs.changed', (params) => - includesWatchPath(params, beforePath) - ) - - const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' - startIndex = relay.messageCount() - process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid( - pidFile, - firstWatcherPid, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` - ) - await relay.waitForNotification(startIndex, 'fs.changed', (params) => - Array.isArray(params.events) - ? params.events.some( - (event) => event.kind === 'overflow' && event.absolutePath === watchRoot - ) - : false - ) - - const status = await relay.request('relay.status') - if (status.pid !== daemon.pid) { - throw new Error('relay.status did not come from the original surviving relay process') - } - const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}` - startIndex = relay.messageCount() - relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` }) - await relay.waitForNotification( - startIndex, - 'pty.data', - (params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker) - ) - - const afterPath = join(watchRoot, 'after.txt') - startIndex = relay.messageCount() - await writeFile(afterPath, 'after') - await relay.waitForNotification(startIndex, 'fs.changed', (params) => - includesWatchPath(params, afterPath) - ) - - relay.notify('fs.unwatch', { rootPath: watchRoot }) - await relay.request('pty.shutdown', { id: spawned.id }) - console.log( - JSON.stringify({ - relayPid: daemon.pid, - killedWatcherPid: firstWatcherPid, - replacementWatcherPid, - faultSignal, - relaySurvived: true, - existingPtySurvived: true, - overflowRefreshDelivered: true, - postCrashEventDelivered: true - }) - ) - } finally { - await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge') - await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon') - if (tempRoot) { - await rm(tempRoot, { recursive: true, force: true }) - } - } -} - -await main() diff --git a/config/scripts/relay-windows-process-tree-staging.mjs b/config/scripts/relay-windows-process-tree-staging.mjs deleted file mode 100644 index 5abd1baf35a4..000000000000 --- a/config/scripts/relay-windows-process-tree-staging.mjs +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Stage the prebuilt `@vscode/windows-process-tree` addon into a Windows relay bundle. - * - * Every desktop package ships relays for every host OS, so a macOS or Linux build - * needs the addon a Windows job compiled: without it a Windows SSH host cannot - * launch the relay outside sshd's job as a standard user. Release builds list the - * arches they require; a local build without the addon ships the scan fallback. - */ -import { copyFileSync } from 'node:fs' -import { join } from 'node:path' -import { - RELAY_WINDOWS_PROCESS_TREE_FILENAME, - isWindowsRelayPlatform -} from '../../src/shared/relay-artifacts.ts' -import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' - -/** `ORCA_REQUIRE_RELAY_NATIVE_ADDONS`: comma-separated arches, or `all`. */ -export function parseRequiredRelayAddonArches(value) { - return (value ?? '') - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) -} - -/** - * @param {{ - * platform: string, - * outDir: string, - * buildDir: string, - * requiredArches: string[], - * log?: (message: string) => void - * }} options - * @returns {'staged' | 'skipped' | 'not-windows'} - */ -export function stageRelayWindowsProcessTreeAddon({ - platform, - outDir, - buildDir, - requiredArches, - log = console.log -}) { - if (!isWindowsRelayPlatform(platform)) { - return 'not-windows' - } - const arch = platform.slice('win32-'.length) - const source = join(buildDir, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME) - const required = requiredArches.includes(arch) || requiredArches.includes('all') - const defect = relayWindowsProcessTreeAddonDefect(source, arch) - if (defect) { - const reason = `${source}: ${defect}` - if (required) { - throw new Error( - `Relay ${platform} needs ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}, but ${reason}. ` + - `On Windows run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch}. ` + - 'CI builds on other OSes download it from the relay-windows-process-tree artifact.' - ) - } - // Never ship a defective binary: the scan fallback beats a relay that loads the wrong addon. - log(`Relay ${platform}: ${reason}; relay will use the PowerShell scan.`) - return 'skipped' - } - copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME)) - return 'staged' -} diff --git a/config/scripts/relay-windows-process-tree-staging.test.mjs b/config/scripts/relay-windows-process-tree-staging.test.mjs deleted file mode 100644 index 07477965d43a..000000000000 --- a/config/scripts/relay-windows-process-tree-staging.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - parseRequiredRelayAddonArches, - stageRelayWindowsProcessTreeAddon -} from './relay-windows-process-tree-staging.mjs' -import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' - -const MACHINE = { x64: 0x8664, arm64: 0xaa64 } -const LAUNCHER = 'spawnOutsideJob\0' - -const roots = [] -afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) - -function peImage(machine, body) { - const header = Buffer.alloc(0x90) - header.write('MZ') - header.writeUInt32LE(0x80, 0x3c) - header.write('PE\0\0', 0x80) - header.writeUInt16LE(machine, 0x84) - return Buffer.concat([header, Buffer.from(body, 'binary')]) -} - -function fixture(addons = {}) { - const root = mkdtempSync(join(tmpdir(), 'relay-process-tree-')) - roots.push(root) - const buildDir = join(root, 'build') - const outDir = join(root, 'out') - mkdirSync(outDir) - for (const [arch, bytes] of Object.entries(addons)) { - mkdirSync(join(buildDir, arch), { recursive: true }) - writeFileSync(join(buildDir, arch, 'windows-process-tree.node'), bytes) - } - const logs = [] - const stage = (platform, requiredArches = []) => - stageRelayWindowsProcessTreeAddon({ - platform, - outDir, - buildDir, - requiredArches, - log: (message) => logs.push(message) - }) - return { buildDir, outDir, logs, stage, staged: join(outDir, 'windows-process-tree.node') } -} - -describe('relay windows-process-tree addon check', () => { - it('accepts only a clean launcher build for the requested machine', () => { - const { buildDir } = fixture({ - x64: peImage(MACHINE.x64, `ntdll.dll\0${LAUNCHER}`), - arm64: peImage(MACHINE.x64, LAUNCHER) - }) - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') - ).toBeNull() - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'arm64/windows-process-tree.node'), 'arm64') - ).toContain('machine 0x8664, not arm64') - }) - - it('rejects a pre-launcher build that is otherwise clean and loadable', () => { - const { buildDir } = fixture({ x64: peImage(MACHINE.x64, 'NtQueryInformationProcess\0') }) - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') - ).toContain('does not export spawnOutsideJob') - }) - - it('rejects the unpatched command-line reader even when it has the launcher', () => { - const { buildDir } = fixture({ x64: peImage(MACHINE.x64, `ReadProcessMemory\0${LAUNCHER}`) }) - expect( - relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') - ).toContain('ReadProcessMemory') - }) -}) - -describe('staging the relay windows-process-tree addon', () => { - it('copies a valid addon into each Windows relay and skips other hosts', () => { - const addon = peImage(MACHINE.arm64, LAUNCHER) - const { stage, staged } = fixture({ arm64: addon }) - expect(stage('linux-x64', ['all'])).toBe('not-windows') - expect(existsSync(staged)).toBe(false) - expect(stage('win32-arm64', ['x64', 'arm64'])).toBe('staged') - expect(readFileSync(staged)).toEqual(addon) - }) - - it('degrades to the scan when an unrequired addon is missing or stale', () => { - const { stage, staged, logs } = fixture({ x64: peImage(MACHINE.x64, 'no launcher') }) - expect(stage('win32-x64')).toBe('skipped') - expect(stage('win32-arm64')).toBe('skipped') - expect(existsSync(staged)).toBe(false) - expect(logs.join('\n')).toMatch(/does not export spawnOutsideJob[\s\S]*is missing/) - }) - - it('fails a release build whose required addon is missing or stale', () => { - const { stage, staged } = fixture({ x64: peImage(MACHINE.x64, 'no launcher') }) - expect(() => stage('win32-x64', ['x64', 'arm64'])).toThrow(/spawnOutsideJob/) - expect(() => stage('win32-arm64', ['all'])).toThrow(/is missing/) - expect(existsSync(staged)).toBe(false) - }) - - it('reads the per-arch requirement list', () => { - expect(parseRequiredRelayAddonArches(undefined)).toEqual([]) - expect(parseRequiredRelayAddonArches(' x64, arm64 ,')).toEqual(['x64', 'arm64']) - }) -}) diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index d4a6d36bc4ed..d58604b63a69 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -12,12 +12,11 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#changed-e2e': { contents: 'read' }, '.github/workflows/e2e.yml#e2e': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-auto-convert-docker': { contents: 'read' }, + '.github/workflows/e2e.yml#orcad-journeys-docker': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-serve-mode-switch': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-serve-mode-switch-windows': { contents: 'read' }, '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' }, - '.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' }, - '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, '.github/workflows/node-server-tests.yml#changes': { contents: 'read' }, '.github/workflows/node-server-tests.yml#desktop_template': { contents: 'read' }, diff --git a/config/scripts/run-local-ssh-browser-routing-e2e.mjs b/config/scripts/run-local-ssh-browser-routing-e2e.mjs deleted file mode 100644 index 4562e21e3b6c..000000000000 --- a/config/scripts/run-local-ssh-browser-routing-e2e.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_LOCAL_SSH_BROWSER: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/local-ssh-browser-routing.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-nested-runtime-ssh-e2e.mjs b/config/scripts/run-nested-runtime-ssh-e2e.mjs deleted file mode 100644 index 577fd40c6bb6..000000000000 --- a/config/scripts/run-nested-runtime-ssh-e2e.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const result = spawnSync( - process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/nested-runtime-ssh-routing.spec.ts', - 'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1' - ], - { - cwd: process.cwd(), - env: { - ...process.env, - ORCA_E2E_NESTED_RUNTIME_SSH: '1', - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_WEB_CLIENT: '1' - }, - stdio: 'inherit' - } -) - -if (result.error) { - throw result.error -} -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs b/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs deleted file mode 100644 index fc1fd5a4e993..000000000000 --- a/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs b/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs deleted file mode 100644 index a4e3fec42ed5..000000000000 --- a/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const extraArgs = process.argv.slice(2) -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], { - stdio: 'inherit', - env -}) - -if (runtime.error) { - throw runtime.error -} -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - { - stdio: 'inherit', - env - } -) - -if (result.error) { - throw result.error -} -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs b/config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs deleted file mode 100644 index 294bf7e2c7b2..000000000000 --- a/config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' - -const extraArgs = process.argv.slice(2) -const { command: pnpm, prefixArgs: pnpmPrefix, shell } = resolvePnpmCliInvocation() -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -const runtime = spawnSync(pnpm, [...pnpmPrefix, 'run', 'ensure:electron-runtime'], { - stdio: 'inherit', - env, - shell -}) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - ...pnpmPrefix, - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headful', - '--workers=1', - ...extraArgs - ], - { - stdio: 'inherit', - env, - shell - } -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs deleted file mode 100644 index 1a67f56cf10c..000000000000 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_LOCAL_SSH_BROWSER: '1', - ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1', - ORCA_E2E_WEB_CLIENT: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane -// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading -// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion. -// -// Deliberately absent, and therefore still covered by no CI trigger: -// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would -// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in -// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at -// all. Recorded as a real gap, not as coverage living somewhere else. -// The bulk-open frame probe runs headed: headless Linux compositing schedules idle RAFs -// roughly 1s apart, so it cannot measure foreground interaction against the same budget. -// -// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project -// grep-inverts away. -// -// Known gaps in SSH e2e coverage, recorded here because nothing else names them: -// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on -// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment -// has the exact wiring to flip it, and the gate contract asserts the current state. -// - Two specs are gated on env vars no workflow sets, so they run nowhere -// and are not Docker-gated, which puts them outside this file's contract: -// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH) -// The nested-runtime runner remains unused by CI. -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/local-ssh-browser-routing.spec.ts', - 'tests/e2e/repo-registration-async.spec.ts', - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-ai-vault-session-history.spec.ts', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', - 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', - 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', - 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', - 'tests/e2e/ssh-docker-half-open-link.spec.ts', - 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts', - 'tests/e2e/ssh-docker-resource-accumulation.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-external-image-preview.spec.ts', - 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', - 'tests/e2e/ssh-native-chat-phone-history.spec.ts', - 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reattach-home-partition.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', - 'tests/e2e/ssh-skill-installation.spec.ts', - 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts', - 'tests/e2e/ssh-startup-local-shadow.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', - 'tests/e2e/terminal-inline-images-ssh.spec.ts', - 'tests/e2e/workspace-layout-oracle-ssh.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--project', - 'electron-headful', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-perf-e2e.mjs b/config/scripts/run-ssh-docker-perf-e2e.mjs deleted file mode 100644 index afa880d8cec4..000000000000 --- a/config/scripts/run-ssh-docker-perf-e2e.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const extraArgs = process.argv.slice(2) -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], { - stdio: 'inherit', - env -}) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-docker-relay-perf.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - { - stdio: 'inherit', - env - } -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs deleted file mode 100644 index b894ac15217d..000000000000 --- a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_WEB_CLIENT: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -// Why one runner: these specs share the Docker SSH rig; startup-exec also joins -// the paired desktop/headless parity proof under the same isolated CI lane. -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-terminal-parking.spec.ts', - 'tests/e2e/terminal-retention-budget.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/paired-startup-exec-readiness.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--project', - 'electron-headful', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs b/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs deleted file mode 100644 index 5fc10e9e491b..000000000000 --- a/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-docker-watcher-isolation.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-staged-upload-reliability.mjs b/config/scripts/run-ssh-staged-upload-reliability.mjs index fb3d00403505..ca17b774bdbc 100644 --- a/config/scripts/run-ssh-staged-upload-reliability.mjs +++ b/config/scripts/run-ssh-staged-upload-reliability.mjs @@ -3,15 +3,10 @@ import { spawnSync } from 'node:child_process' const defaultFiles = [ 'src/main/ssh/sftp-upload.test.ts', 'src/main/ssh/ssh-file-transfer-abort.test.ts', - 'src/main/ssh/ssh-relay-deploy-staged-upload.test.ts', - 'src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts', - 'src/main/ssh/ssh-relay-sftp-namespace-install.test.ts', 'src/main/ssh/ssh-relay-install-namespace.test.ts', - 'src/main/ssh/ssh-relay-upload-stage-commands.test.ts', 'src/main/ssh/sftp-namespace-resolution.test.ts', 'src/main/ssh/ssh-connection-sftp-wire.test.ts', - 'src/main/ssh/ssh-remote-commands.test.ts', - 'src/main/ssh/ssh-relay-cross-version-isolation.test.ts' + 'src/main/ssh/ssh-remote-commands.test.ts' ] const cliArguments = process.argv.slice(2) diff --git a/config/scripts/run-typecheck-projects-in-parallel.mjs b/config/scripts/run-typecheck-projects-in-parallel.mjs index 0296aac9e20e..4c3e394c74fc 100644 --- a/config/scripts/run-typecheck-projects-in-parallel.mjs +++ b/config/scripts/run-typecheck-projects-in-parallel.mjs @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process' +import { spawnProcess } from '@orca/process-host' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { availableParallelism, totalmem } from 'node:os' @@ -59,30 +59,72 @@ export function planTypecheckBatches(projects, { budgetGib, parallelism }) { const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) -export function resolveProjectCompiler(config, root = repoRoot) { +export function resolveProjectCompilerInfo(config, root = repoRoot) { const require = createRequire(join(root, dirname(config), 'package.json')) const manifestPath = require.resolve('typescript/package.json') - const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8')) - return join(dirname(manifestPath), typeof bin === 'string' ? bin : bin.tsc) + const { bin, version } = JSON.parse(readFileSync(manifestPath, 'utf8')) + return { program: join(dirname(manifestPath), typeof bin === 'string' ? bin : bin.tsc), version } } -function checkProject(config) { - return new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [resolveProjectCompiler(config), '--noEmit', '-p', config], - { - cwd: repoRoot, - stdio: 'inherit' - } - ) +export function resolveProjectCompiler(config, root = repoRoot) { + return resolveProjectCompilerInfo(config, root).program +} + +export function parseTypecheckOptions(args) { + const options = { compiler: 'native', bunConcurrency: 1, bunThreads: 4 } + for (const argument of args) { + if (argument === '--compiler=bun' || argument === '--compiler=native') { + options.compiler = argument.slice('--compiler='.length) + } else if (argument.startsWith('--bun-concurrency=')) { + options.bunConcurrency = Number(argument.slice('--bun-concurrency='.length)) + } else if (argument.startsWith('--bun-threads=')) { + options.bunThreads = Number(argument.slice('--bun-threads='.length)) + } else { + throw new Error(`Unknown typecheck option: ${argument}`) + } + } + for (const value of [options.bunConcurrency, options.bunThreads]) { + if (!Number.isInteger(value) || value < 1 || value > 4) { + throw new Error('Bun concurrency and threads must be integers from 1 to 4') + } + } + return options +} + +export function typecheckBatches(projects, { compiler, bunConcurrency, memoryBytes, parallelism }) { + return planTypecheckBatches( + compiler === 'bun' ? projects.map((project) => ({ ...project, heapGib: 4 })) : projects, + { + budgetGib: admissibleHeapGib(memoryBytes), + parallelism: compiler === 'bun' ? Math.min(parallelism, bunConcurrency) : parallelism + } + ) +} +function checkProject(config, { compiler, bunThreads, root }) { + return new Promise((resolve, reject) => { + const installedCompiler = resolveProjectCompilerInfo(config, root) + // Bun 1.4.3 targets TypeScript 7.0.2; compiler upgrades require a new parity comparison. + if (compiler === 'bun' && installedCompiler.version !== '7.0.2') { + throw new Error( + `Bun CI compiler targets TypeScript 7.0.2, but ${config} resolves ${installedCompiler.version}` + ) + } + const child = spawnProcess({ + program: compiler === 'bun' ? 'bun' : process.execPath, + args: + compiler === 'bun' + ? ['check', '-p', config, '--threads', String(bunThreads), '--no-pretty', '--all'] + : [installedCompiler.program, '--noEmit', '-p', config], + cwd: root, + stdio: 'inherit' + }) child.on('error', reject) - child.on('exit', (code, signal) => { + child.on('close', (code, signal) => { if (signal) { - reject(new Error(`tsc ${config} exited with signal ${signal}`)) + reject(new Error(`${compiler} ${config} exited with signal ${signal}`)) } else if (code !== 0) { - reject(new Error(`tsc ${config} exited with code ${code}`)) + reject(new Error(`${compiler} ${config} exited with code ${code}`)) } else { resolve() } @@ -90,28 +132,33 @@ function checkProject(config) { }) } -async function runTypecheckProjects() { - const batches = planTypecheckBatches(TYPECHECK_PROJECTS, { - budgetGib: admissibleHeapGib(totalmem()), - parallelism: availableParallelism() - }) - +export async function runTypecheckProjects({ + compiler = 'native', + bunConcurrency = 1, + bunThreads = 4, + root = repoRoot, + projects = TYPECHECK_PROJECTS, + memoryBytes = totalmem(), + parallelism = availableParallelism() +} = {}) { + const batches = typecheckBatches(projects, { compiler, bunConcurrency, memoryBytes, parallelism }) // Every batch runs even after one fails, so a single broken project still reports the rest. const failures = [] for (const batch of batches) { - const results = await Promise.allSettled(batch.map((project) => checkProject(project.config))) + const results = await Promise.allSettled( + batch.map((project) => checkProject(project.config, { compiler, bunThreads, root })) + ) for (const result of results) { if (result.status === 'rejected') { failures.push(result.reason) } } } - return failures } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const failures = await runTypecheckProjects() + const failures = await runTypecheckProjects(parseTypecheckOptions(process.argv.slice(2))) if (failures.length > 0) { for (const failure of failures) { console.error(failure.message ?? failure) diff --git a/config/scripts/run-typecheck-projects-in-parallel.test.mjs b/config/scripts/run-typecheck-projects-in-parallel.test.mjs index c268810179a0..ef695a7124b5 100644 --- a/config/scripts/run-typecheck-projects-in-parallel.test.mjs +++ b/config/scripts/run-typecheck-projects-in-parallel.test.mjs @@ -1,5 +1,6 @@ import { existsSync, + readFileSync, mkdirSync, mkdtempSync, readdirSync, @@ -15,7 +16,10 @@ import { TYPECHECK_PROJECTS, admissibleHeapGib, planTypecheckBatches, - resolveProjectCompiler + resolveProjectCompiler, + parseTypecheckOptions, + typecheckBatches, + runTypecheckProjects } from './run-typecheck-projects-in-parallel.mjs' const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) @@ -126,3 +130,93 @@ describe('workspace package test typecheck', () => { } }) }) + +describe('CI compiler selection', () => { + it('keeps native incremental checking as the local default', () => { + expect(parseTypecheckOptions([]).compiler).toBe('native') + }) + + it.each(['--compiler=typo', '--bun-concurrency=0', '--bun-threads=NaN', '--bun-concurrency=5'])( + 'rejects unsafe or unknown options: %s', + (argument) => { + expect(() => parseTypecheckOptions([argument])).toThrow() + } + ) + + it('bounds Bun project concurrency and reserves memory for overlapping checks', () => { + const options = parseTypecheckOptions([ + '--compiler=bun', + '--bun-concurrency=2', + '--bun-threads=2' + ]) + const batches = typecheckBatches(TYPECHECK_PROJECTS, { + ...options, + memoryBytes: CI_RUNNER.totalBytes, + parallelism: 4 + }) + expect(batches.map((batch) => batch.length)).toEqual([2, 2]) + expect(batches.flat().map((project) => project.config)).toEqual( + TYPECHECK_PROJECTS.map((project) => project.config) + ) + expect( + typecheckBatches(TYPECHECK_PROJECTS, { + ...options, + memoryBytes: 4 * 1024 ** 3, + parallelism: 4 + }).every((batch) => batch.length === 1) + ).toBe(true) + }) + + it('rejects a mismatched package compiler before invoking Bun', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-bun-compiler-version-'))) + try { + mkdirSync(join(root, 'node_modules', 'typescript'), { recursive: true }) + writeFileSync( + join(root, 'node_modules', 'typescript', 'package.json'), + JSON.stringify({ name: 'typescript', version: '8.0.0', bin: 'compiler.cjs' }) + ) + const failures = await runTypecheckProjects({ + compiler: 'bun', + root, + projects: [{ config: 'tsconfig.json', heapGib: 1 }] + }) + expect(failures).toHaveLength(1) + expect(failures[0].message).toContain('resolves 8.0.0') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('continues to later projects after a compiler exits with an error', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-typecheck-failure-'))) + try { + const compilerDir = join(root, 'node_modules', 'typescript') + mkdirSync(compilerDir, { recursive: true }) + mkdirSync(join(root, 'config')) + writeFileSync( + join(compilerDir, 'package.json'), + JSON.stringify({ name: 'typescript', bin: { tsc: 'compiler.cjs' } }) + ) + writeFileSync( + join(compilerDir, 'compiler.cjs'), + "const fs = require('node:fs'); const config = process.argv.at(-1); fs.appendFileSync('checked.txt', config + '\\n'); process.exit(config.includes('broken') ? 1 : 0)" + ) + const failures = await runTypecheckProjects({ + root, + projects: [ + { config: 'config/broken.json', heapGib: 1 }, + { config: 'config/healthy.json', heapGib: 1 } + ], + memoryBytes: 2 * 1024 ** 3, + parallelism: 1 + }) + expect(failures).toHaveLength(1) + expect(failures[0].message).toContain('broken.json exited with code 1') + expect(readFileSync(join(root, 'checked.txt'), 'utf8')).toBe( + 'config/broken.json\nconfig/healthy.json\n' + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/smoke-managed-hook-runtime-node18.mjs b/config/scripts/smoke-managed-hook-runtime-node18.mjs deleted file mode 100644 index 40c44285f6d8..000000000000 --- a/config/scripts/smoke-managed-hook-runtime-node18.mjs +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict' -import { constants } from 'node:fs' -import { access, mkdtemp, readFile, rm } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const PLATFORMS = [ - 'linux-x64', - 'linux-arm64', - 'darwin-x64', - 'darwin-arm64', - 'win32-x64', - 'win32-arm64' -] -const ROOT = fileURLToPath(new URL('../..', import.meta.url)) -const require = createRequire(import.meta.url) - -assert.match(process.versions.node, /^18\./, 'This smoke test must run under Node 18') - -const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-node18-')) -const originalHome = process.env.HOME -const originalUserProfile = process.env.USERPROFILE -const originalGetuid = process.getuid -process.env.HOME = home -process.env.USERPROFILE = home -process.getuid = undefined - -try { - const runtimes = PLATFORMS.map((platform) => { - const artifact = join(ROOT, 'out', 'relay', platform, 'managed-hook-runtime.js') - const runtime = require(artifact) - assert.equal(typeof runtime.installManagedHooks, 'function', `${platform} installer export`) - return runtime - }) - - const summary = await runtimes[0].installManagedHooks({ agents: ['codex', 'claude'] }) - assert.deepEqual(summary, { installers: 2, errors: 0 }) - - const codexHooks = await readFile(join(home, '.codex', 'hooks.json'), 'utf8') - const claudeSettings = await readFile(join(home, '.claude', 'settings.json'), 'utf8') - assert.match(codexHooks, /\.orca\/agent-hooks\/codex-hook\.sh/) - assert.match(claudeSettings, /\.orca\/agent-hooks\/claude-hook\.sh/) - await access(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'), constants.X_OK) - await access(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), constants.X_OK) -} finally { - if (originalHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = originalHome - } - if (originalUserProfile === undefined) { - delete process.env.USERPROFILE - } else { - process.env.USERPROFILE = originalUserProfile - } - process.getuid = originalGetuid - await rm(home, { recursive: true, force: true }) -} - -console.log('Node 18 managed-hook runtime smoke passed for all relay platforms.') diff --git a/config/scripts/ssh-browser-e2e-routing.test.mjs b/config/scripts/ssh-browser-e2e-routing.test.mjs index d7abce7d39b5..e0ffd2f8ad92 100644 --- a/config/scripts/ssh-browser-e2e-routing.test.mjs +++ b/config/scripts/ssh-browser-e2e-routing.test.mjs @@ -3,30 +3,10 @@ import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parse } from 'yaml' import { expect, it } from 'vitest' -import { selectPrE2eSpecs, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs' +import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const root = resolve(import.meta.dirname, '../..') const workflow = parse(readFileSync(join(root, '.github/workflows/e2e.yml'), 'utf8')) -const runner = readFileSync(join(root, 'config/scripts/run-ssh-docker-e2e.mjs'), 'utf8') - -it('routes SSH browser specs to a lane that enables their opt-ins', () => { - const changedRun = workflow.jobs['changed-e2e'].steps.find( - (step) => step.name === 'Run changed E2E specs' - ) - for (const [spec, flag] of [ - ['tests/e2e/local-ssh-browser-routing.spec.ts', 'ORCA_E2E_LOCAL_SSH_BROWSER'], - [ - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - 'ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER' - ] - ]) { - expect(runner).toContain(`'${spec}'`) - expect(runner).toContain(`${flag}: '1'`) - expect(workflow.jobs['ssh-docker-watcher-isolation'].if).toContain(spec) - expect(DEDICATED_E2E_SPECS).toContain(spec) - expect(changedRun.run).toContain('node config/scripts/ci-e2e-job-selection.mjs') - } -}) it('executes both Docker network routes in a Node job with their opt-in enabled', () => { const spec = 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' @@ -62,24 +42,3 @@ it('executes both Docker network routes in a Node job with their opt-in enabled' spec ) }) - -it('runs the local SSH browser spec when its route source changes', () => { - const spec = 'tests/e2e/local-ssh-browser-routing.spec.ts' - for (const changed of [ - 'src/main/browser/local-ssh-browser-route.ts', - 'src/main/browser/local-ssh-browser-partitions.ts', - 'src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts', - 'src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.tsx', - 'src/renderer/src/lib/worktree-host-connection-phase.ts' - ]) { - expect(selectPrE2eSpecs([changed])).toContain(spec) - expect(shouldRunReusablePrE2e([changed])).toBe(true) - } - for (const unrelated of [ - 'src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx', - 'src/main/browser/local-ssh-browser-route.test.ts', - 'src/renderer/src/components/browser-pane/BrowserPane.tsx' - ]) { - expect(selectPrE2eSpecs([unrelated])).not.toContain(spec) - } -}) diff --git a/config/scripts/ssh-localhost-e2e-routing.test.mjs b/config/scripts/ssh-localhost-e2e-routing.test.mjs deleted file mode 100644 index be149f954763..000000000000 --- a/config/scripts/ssh-localhost-e2e-routing.test.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' -import { existsSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { parse } from 'yaml' -import { expect, it } from 'vitest' -import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' - -const workflow = parse( - readFileSync(resolve(import.meta.dirname, '../../.github/workflows/e2e.yml'), 'utf8') -) - -it('gives the localhost SSH journey its same-filesystem server and agent prerequisite', () => { - const spec = 'tests/e2e/ssh-localhost.spec.ts' - const job = workflow.jobs['ssh-localhost'] - expect(job.if).toContain("inputs.test_files == ''") - expect(job.if).toContain(spec) - expect(job['runs-on']).toBe('ubuntu-latest') - expect(job.needs).toEqual(['build', 'prepare-native-cache']) - const setup = job.steps.find((step) => step.name === 'Start isolated localhost SSH server') - expect(setup.run).toContain('ListenAddress 127.0.0.1') - expect(setup.run).toContain('PasswordAuthentication no') - expect(setup.run).toContain('UsePAM yes') - expect(setup.run).toContain('mkdir -p "$HOME/.pi/agent"') - for (const key of ['ORCA_E2E_SSH_PORT', 'ORCA_E2E_SSH_USER', 'ORCA_E2E_SSH_IDENTITY_FILE']) { - expect(setup.run).toContain(key) - } - const run = job.steps.find((step) => step.name === 'Run localhost SSH terminal and hook journey') - expect(run.env.ORCA_E2E_SSH_LOCALHOST).toBe('1') - expect(run.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS).toBe('1') - expect(run.run).toContain(spec) - expect(run.run).toContain('--project=electron-headless') - expect(run.run).not.toContain('--retries') - expect(run['continue-on-error']).toBeUndefined() - expect(DEDICATED_E2E_SPECS).toContain(spec) -}) - -it('selects the localhost journey for its remote hook authorities', () => { - const spec = 'tests/e2e/ssh-localhost.spec.ts' - for (const file of [ - 'src/relay/relay-agent-hook-runtime.ts', - 'src/wsl-guest/agent-hook-server.ts', - 'src/wsl-guest/plugin-overlay.ts', - 'src/main/agent-hooks/server.ts', - 'src/main/ssh/ssh-relay-session.ts', - 'src/shared/agent-hook-relay.ts' - ]) { - expect(existsSync(resolve(import.meta.dirname, '../..', file)), file).toBe(true) - expect(selectPrE2eSpecs([file])).toContain(spec) - } - expect(selectPrE2eSpecs(['src/renderer/src/components/Unrelated.tsx'])).not.toContain(spec) -}) diff --git a/config/scripts/ssh-relay-import-boundary.test.mjs b/config/scripts/ssh-relay-import-boundary.test.mjs deleted file mode 100644 index ad5570ce2f32..000000000000 --- a/config/scripts/ssh-relay-import-boundary.test.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { globSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { expect, it } from 'vitest' -import { collectModuleSpecifiers } from './static-module-specifiers.mjs' -import { isTestOnlySourcePath } from './test-only-source-path.mjs' - -const ROOT = path.resolve(import.meta.dirname, '..', '..') -const RELAY_SOURCE = 'src/relay' - -// Why: the SSH relay is being retired; only its own tests may reach into it, and this list only shrinks. -const RELAY_TEST_IMPORTERS = [ - 'src/main/ai-vault-search/session-search-scope-entry-points.test.ts', - 'src/main/ai-vault/antigravity-index-file-admission.test.ts', - 'src/main/ai-vault/remote-session-large-transcripts.test.ts', - 'src/main/git/git-network-safety-real-git.test.ts', - 'src/main/git/source-control/blob-absence-real-git.test.ts', - 'src/main/git/source-control/bulk-pathspec-stdin.test.ts', - 'src/main/git/source-control/staging-discard-index-safety.test.ts', - 'src/main/git/status-branch-compare-real-ref.test.ts', - 'src/main/git/status-branch-line-total-relay-parity.test.ts', - 'src/main/git/status-conflict-overlap.bench.test.ts', - 'src/main/git/worktree-rebase-update-refs-real-git.test.ts', - 'src/main/git/worktree-safety-real-git.test.ts', - 'src/main/ipc/filesystem-quick-open-options.integration.test.ts', - 'src/main/ipc/worktree-push-target-cleanup.test.ts', - 'src/main/ipc/worktree-push-target-reconciliation.test.ts', - 'src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts', - 'src/main/plugins/plugin-host-conformance.test.ts', - 'src/main/providers/ssh-directory-listing-options.test.ts', - 'src/main/providers/ssh-review-draft-context.test.ts', - 'src/main/providers/terminal-path-existence-batch.integration.test.ts', - 'src/main/runtime/runtime-file-path-existence.test.ts', - 'src/main/runtime/runtime-repository-ref-queries.test.ts', - 'src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts', - 'src/main/shell-wrapper-generated-file-snapshot.test.ts', - 'src/main/ssh/ssh-remote-cli-host-passthrough.test.ts', - 'src/main/zsh-scoped-histfile.live-shell.test.ts', - 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', - 'tests/tools/omp-relay-close-lifecycle.test.mjs' -] - -function importsRelay(file, contents) { - // Why: parsing every source file overruns the test timeout on a loaded runner. - if (!contents.includes('relay')) { - return false - } - return collectModuleSpecifiers(file, contents).some((specifier) => { - if (!specifier.startsWith('.')) { - return false - } - const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)) - return target === RELAY_SOURCE || target.startsWith(`${RELAY_SOURCE}/`) - }) -} - -it('keeps everything outside the SSH relay from importing it', () => { - const importers = globSync( - ['src', 'config', 'tests', 'mobile/src', 'mobile/app', 'mobile/scripts'].map( - (directory) => `${directory}/**/*.{ts,tsx,mts,cts,js,mjs,cjs}` - ), - { - cwd: ROOT, - exclude: [`${RELAY_SOURCE}/**`, '**/node_modules/**', '**/dist/**', '**/out/**'] - } - ) - .map((file) => file.replaceAll('\\', '/')) - .filter((file) => importsRelay(file, readFileSync(path.join(ROOT, file), 'utf8'))) - .sort() - - expect(importers.filter((file) => !isTestOnlySourcePath(file))).toEqual([]) - expect(importers).toEqual(RELAY_TEST_IMPORTERS) -}) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 36763a4a3a99..2519b78ef035 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -9,6 +9,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/ssh/ssh-remote-commands.test.ts', 'src/packages/process-host/src/run-process.test.ts', 'tests/e2e/cursor-quota-transport.unit.test.ts', + 'tests/e2e/helpers/electron-process-shutdown.unit.test.ts', 'src/main/ai-vault-search/session-search-index-writer.test.ts', 'src/main/ai-vault/session-scanner-unlimited-dedup.test.ts', 'src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts', @@ -53,8 +54,6 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/computer/desktop-script-serve-channel.test.ts', 'src/main/providers/local-pty-child-process-verdict.test.ts', 'src/main/providers/working-directory-validation.test.ts', - 'src/relay/node-pty-binding-survey.test.ts', - 'src/relay/relay-runtime-self-test.test.ts', 'src/main/pty/posix-pty-process-groups.integration.test.ts', 'src/shared/ephemeral-vm-recipe-output-capture.test.ts', 'src/main/gitlab/gitlab-known-host-retirement.test.ts', @@ -82,10 +81,9 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-shared-server-probe.test.ts', 'src/main/opencode/opencode-launch-model-context-supervision.test.ts', 'src/main/hermes/hermes-config-remote-adapter.test.ts', - 'src/relay/relay-reconnect-listener-peer-write-error.test.ts', - 'src/relay/relay-primary-channel.test.ts', 'src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts', 'src/main/ipc/orca-profiles-switch-persistence.test.ts', + 'src/main/ipc/settings-delayed-response.test.ts', 'src/main/runtime/structured-agent-session-startup-tab-restore.test.ts', 'src/shared/remote-runtime-shared-control-connection.test.ts', 'src/main/runtime/relay/relay-control-client.test.ts', diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index d33348671932..8e7a97d517e6 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -18,6 +18,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts', 'src/main/native-chat/transcript-opencode-sqlite-query.test.ts', 'src/main/native-chat/transcript-opencode-subscribe.test.ts', + 'src/main/native-chat/transcript-zcode.test.ts', 'src/main/native-chat/transcript-opencode2-sqlite.test.ts', 'src/main/opencode-usage/cache-read-reproduction.test.ts', 'src/main/opencode-usage/opencode2-session-usage.test.ts', @@ -147,7 +148,6 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts', - 'src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts', @@ -194,8 +194,6 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-claude-auth-policy-wiring.test.ts', 'src/main/runtime/structured-codex-child-work-runtime.test.ts', 'src/main/runtime/structured-worker-at-rest.test.ts', - 'src/main/ssh/ssh-remote-orca-cli.test.ts', - 'src/main/ssh/ssh-remote-orchestration-compatibility.test.ts', 'tests/e2e/completed-worker-retirement-resume.unit.test.ts', 'tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts', 'tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts' diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 9b79d5241c79..ccf1c30759d3 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -59,7 +59,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts', 'src/main/ipc/ssh-host-partition-session-export.test.ts', 'src/main/ipc/ssh-host-server-on-connect-wiring.test.ts', - 'src/main/ipc/ssh-managed-server-move-conversion.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts', diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index a4a267b45cee..afd6af518d13 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -39,16 +39,8 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ // Benchmarks, repros and e2e drivers — developer-invoked or Linux-only in CI. 'config/scripts/run-ai-vault-typing-bench.mjs', 'config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs', - 'config/scripts/run-local-ssh-browser-routing-e2e.mjs', 'config/scripts/run-multi-client-navigation-e2e.mjs', 'config/scripts/run-multi-workspace-typing-bench.mjs', - 'config/scripts/run-nested-runtime-ssh-e2e.mjs', - 'config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs', - 'config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs', - 'config/scripts/run-ssh-docker-e2e.mjs', - 'config/scripts/run-ssh-docker-perf-e2e.mjs', - 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs', - 'config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs', 'config/scripts/run-ssh-staged-upload-reliability.mjs', 'config/scripts/run-terminal-ibus-hangul-e2e.mjs', 'config/scripts/run-terminal-scale-perf-e2e.mjs', diff --git a/config/scripts/windows-ssh-attach-console-repro.mjs b/config/scripts/windows-ssh-attach-console-repro.mjs deleted file mode 100644 index ce0b9b1d40aa..000000000000 --- a/config/scripts/windows-ssh-attach-console-repro.mjs +++ /dev/null @@ -1,436 +0,0 @@ -import { spawn, spawnSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { createConnection } from 'node:net' -import { join, resolve } from 'node:path' - -const projectDir = resolve(import.meta.dirname, '..', '..') -const relayBuildDir = join(projectDir, 'out', 'relay', 'win32-x64') -const SENTINEL = Buffer.from('ORCA-RELAY v0.1.0 READY\n') -const HEADER_LENGTH = 13 -const REGRESSION_TIMEOUT_MS = 15_000 -const NODE_PTY_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' - -if (process.platform !== 'win32') { - console.log('SKIP: Windows ConPTY regression only runs on Windows.') - process.exit(0) -} - -const options = parseArgs(process.argv.slice(2)) -const nodePath = resolve(options.node ?? process.execPath) -const nodePtyDir = resolve(options.nodePty ?? join(projectDir, 'node_modules', 'node-pty')) -const expectFailure = options.expect === 'attach-console-failure' - -for (const required of [ - nodePath, - nodePtyDir, - join(relayBuildDir, 'relay.js'), - join(relayBuildDir, '.version') -]) { - if (!existsSync(required)) { - throw new Error(`Required regression input is missing: ${required}`) - } -} - -const runDir = mkdtempSync(join(projectDir, '.issue-9586-relay-repro-')) -const relayPath = join(runDir, 'relay.js') -const stdoutLog = join(runDir, 'relay.log') -const stderrLog = join(runDir, 'relay.err.log') -const socketPath = `\\\\.\\pipe\\orca-issue-9586-${process.pid}-${Date.now()}` -let relayPid - -try { - prepareRelayTree(runDir, nodePtyDir) - if (!options.skipRelayPatch) { - applyPackagedNodePtyPatch(nodePath, runDir) - } - relayPid = launchRelayWithoutConsole({ - nodePath, - relayPath, - runDir, - socketPath, - stdoutLog, - stderrLog - }) - await waitForPipe(socketPath, 5_000) - const observation = await exerciseRelayClient({ - nodePath, - relayPath, - runDir, - socketPath, - shell: options.shell - }) - await waitForExit(relayPid, 8_000) - - const relayStdout = readIfPresent(stdoutLog) - const relayStderr = readIfPresent(stderrLog) - const attachConsoleFailed = relayStderr.includes('Error: AttachConsole failed') - const installedNodePtyDir = join(runDir, 'node_modules', 'node-pty') - const agentPath = join(installedNodePtyDir, 'lib', 'conpty_console_list_agent.js') - const nativeBindingPath = join( - installedNodePtyDir, - 'prebuilds', - `${process.platform}-${process.arch}`, - 'conpty.node' - ) - const summary = { - node: observation.nodeVersion, - nodePty: JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')).version, - shell: options.shell, - relayPid, - handshake: observation.handshake, - ptySpawn: observation.ptySpawn, - ptyShutdown: observation.ptyShutdown, - relayAliveAfterPtyShutdown: observation.relayAliveAfterPtyShutdown, - bridgeExit: observation.bridgeExit, - relayPatchApplied: !options.skipRelayPatch, - agentSha256: fileSha256(agentPath), - nativeBindingPresent: existsSync(nativeBindingPath), - attachConsoleFailed, - relayExitedAfterClientDisconnect: !isProcessAlive(relayPid) - } - console.log(JSON.stringify(summary, null, 2)) - - if (expectFailure !== attachConsoleFailed) { - throw new Error( - expectFailure - ? `Expected the real console-list agent to fail AttachConsole. stderr:\n${relayStderr}` - : `The real console-list agent failed AttachConsole. stderr:\n${relayStderr}` - ) - } - if ( - !observation.handshake || - !observation.ptySpawn || - !observation.ptyShutdown || - !observation.relayAliveAfterPtyShutdown - ) { - throw new Error( - `Relay lifecycle did not complete. stdout:\n${relayStdout}\nstderr:\n${relayStderr}` - ) - } -} finally { - if (relayPid && isProcessAlive(relayPid)) { - stopExactProcess(relayPid, relayPath) - } - rmSync(runDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) -} - -function parseArgs(args) { - const parsed = { expect: 'clean', shell: 'cmd.exe', skipRelayPatch: false } - for (let index = 0; index < args.length; index++) { - const flag = args[index] - if (flag === '--skip-relay-patch') { - parsed.skipRelayPatch = true - continue - } - const value = args[index + 1] - if (!value || !['--node', '--node-pty', '--expect', '--shell'].includes(flag)) { - throw new Error( - 'Usage: node windows-ssh-attach-console-repro.mjs [--node PATH] [--node-pty DIR] [--shell PATH] [--skip-relay-patch] [--expect clean|attach-console-failure]' - ) - } - if (flag === '--node') { - parsed.node = value - } - if (flag === '--node-pty') { - parsed.nodePty = value - } - if (flag === '--expect') { - parsed.expect = value - } - if (flag === '--shell') { - parsed.shell = value - } - index++ - } - if (!['clean', 'attach-console-failure'].includes(parsed.expect)) { - throw new Error(`Unsupported expectation: ${parsed.expect}`) - } - return parsed -} - -function prepareRelayTree(runDir, nodePtyDir) { - for (const filename of [ - 'relay.js', - 'relay-watcher.js', - 'relay-ai-vault-service.js', - 'managed-hook-runtime.js', - NODE_PTY_PATCH_FILENAME, - '.version' - ]) { - copyFileSync(join(relayBuildDir, filename), join(runDir, filename)) - } - cpSync(nodePtyDir, join(runDir, 'node_modules', 'node-pty'), { recursive: true }) -} - -function applyPackagedNodePtyPatch(nodePath, runDir) { - const result = spawnSync(nodePath, [join(runDir, NODE_PTY_PATCH_FILENAME)], { - cwd: runDir, - encoding: 'utf8', - windowsHide: true - }) - if (result.status !== 0) { - throw new Error(`Packaged node-pty patch failed: ${result.stderr || result.stdout}`) - } -} - -function launchRelayWithoutConsole({ - nodePath, - relayPath, - runDir, - socketPath, - stdoutLog, - stderrLog -}) { - const relayArgs = [ - quoteWindowsArg(nodePath), - quoteWindowsArg(relayPath), - '--detached', - '--grace-time', - '5', - '--sock-path', - quoteWindowsArg(socketPath), - '--endpoint-dir', - quoteWindowsArg(join(runDir, 'endpoint')), - '--log-file', - quoteWindowsArg(stdoutLog), - `1>${quoteWindowsArg(stdoutLog)}`, - `2>${quoteWindowsArg(stderrLog)}` - ].join(' ') - const commandLine = `cmd.exe /d /s /c "${relayArgs}"` - const script = [ - `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(commandLine)}; CurrentDirectory = ${powerShellLiteral(runDir)} }`, - `if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`, - '$result.ProcessId' - ].join('; ') - const launched = runPowerShell(script) - const pid = Number(launched.stdout.trim()) - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error(`Could not parse detached relay pid from: ${launched.stdout}`) - } - return pid -} - -function exerciseRelayClient({ nodePath, relayPath, runDir, socketPath, shell }) { - return new Promise((resolvePromise, rejectPromise) => { - const bridge = spawn(nodePath, [relayPath, '--connect', '--sock-path', socketPath], { - cwd: runDir, - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'] - }) - const observation = { - nodeVersion: readNodeVersion(nodePath), - handshake: false, - ptySpawn: false, - ptyShutdown: false, - relayAliveAfterPtyShutdown: false, - bridgeExit: null - } - let stderr = '' - let buffer = Buffer.alloc(0) - let sentinelRead = false - let outgoingSequence = 1 - let settled = false - const timeout = setTimeout( - () => finish(new Error(`Timed out waiting for relay client lifecycle. stderr:\n${stderr}`)), - REGRESSION_TIMEOUT_MS - ) - - bridge.stderr.on('data', (data) => { - stderr += data.toString() - }) - bridge.on('error', finish) - bridge.on('close', (code, signal) => { - observation.bridgeExit = { code, signal } - if (!settled && observation.relayAliveAfterPtyShutdown) { - finish() - } else if (!settled) { - finish(new Error(`Relay bridge closed before lifecycle completed. stderr:\n${stderr}`)) - } - }) - bridge.stdout.on('data', (data) => { - try { - buffer = Buffer.concat([buffer, data]) - if (!sentinelRead) { - const sentinelIndex = buffer.indexOf(SENTINEL) - if (sentinelIndex === -1) { - return - } - buffer = buffer.subarray(sentinelIndex + SENTINEL.length) - sentinelRead = true - observation.handshake = true - request(1, 'pty.spawn', { - shellOverride: shell, - cwd: projectDir, - cols: 80, - rows: 24, - env: {} - }) - } - for (const message of drainMessages()) { - if (message.id === 1) { - throwResponseError(message) - observation.ptySpawn = true - request(2, 'pty.shutdown', { id: message.result.id, immediate: true }) - } else if (message.id === 2) { - throwResponseError(message) - observation.ptyShutdown = true - request(3, 'relay.status', {}) - } else if (message.id === 3) { - throwResponseError(message) - observation.relayAliveAfterPtyShutdown = message.result.ptys.active === 0 - bridge.stdin.end() - } - } - } catch (error) { - finish(error) - } - }) - - function request(id, method, params) { - bridge.stdin.write(encodeRequestFrame({ id, method, params }, outgoingSequence++)) - } - - function drainMessages() { - const messages = [] - while (buffer.length >= HEADER_LENGTH) { - const type = buffer[0] - const payloadLength = buffer.readUInt32BE(9) - if (buffer.length < HEADER_LENGTH + payloadLength) { - break - } - const payload = buffer.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength) - buffer = buffer.subarray(HEADER_LENGTH + payloadLength) - if (type === 1) { - messages.push(JSON.parse(payload.toString('utf8'))) - } - } - return messages - } - - function finish(error) { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - bridge.kill() - rejectPromise(error) - } else { - resolvePromise(observation) - } - } - }) -} - -function waitForPipe(socketPath, timeoutMs) { - const deadline = Date.now() + timeoutMs - return new Promise((resolvePromise, rejectPromise) => { - const attempt = () => { - const socket = createConnection(socketPath) - let settled = false - const retry = () => { - if (settled) { - return - } - settled = true - socket.destroy() - if (Date.now() >= deadline) { - rejectPromise(new Error(`Detached relay did not listen on ${socketPath}`)) - } else { - setTimeout(attempt, 50) - } - } - socket.once('connect', () => { - if (settled) { - return - } - settled = true - socket.destroy() - resolvePromise() - }) - socket.once('error', retry) - socket.setTimeout(250, retry) - } - attempt() - }) -} - -function encodeRequestFrame({ id, method, params }, sequence) { - const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, method, params })) - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = 1 - header.writeUInt32BE(sequence, 1) - header.writeUInt32BE(0, 5) - header.writeUInt32BE(payload.length, 9) - return Buffer.concat([header, payload]) -} - -function throwResponseError(message) { - if (message.error) { - throw new Error(`Relay RPC failed: ${message.error.message}`) - } -} - -function readNodeVersion(nodePath) { - return spawnSync(nodePath, ['--version'], { encoding: 'utf8' }).stdout.trim() -} - -function waitForExit(pid, timeoutMs) { - const deadline = Date.now() + timeoutMs - return new Promise((resolvePromise) => { - const poll = () => { - if (!isProcessAlive(pid) || Date.now() >= deadline) { - resolvePromise() - return - } - setTimeout(poll, 50) - } - poll() - }) -} - -function isProcessAlive(pid) { - const result = runPowerShell( - `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'ALIVE' }` - ) - return result.stdout.includes('ALIVE') -} - -function stopExactProcess(pid, relayPath) { - const script = [ - `$process = Get-CimInstance Win32_Process -Filter ${powerShellLiteral(`ProcessId = ${pid}`)}`, - `if ($process -and $process.CommandLine -like ${powerShellLiteral(`*${relayPath}*`)}) { Stop-Process -Id ${pid} -Force }` - ].join('; ') - runPowerShell(script) -} - -function runPowerShell(script) { - const encoded = Buffer.from(script, 'utf16le').toString('base64') - const result = spawnSync( - 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], - { encoding: 'utf8' } - ) - if (result.status !== 0) { - throw new Error(`PowerShell failed (${result.status}): ${result.stderr || result.stdout}`) - } - return result -} - -function quoteWindowsArg(value) { - return `"${value.replaceAll('"', '\\"')}"` -} - -function powerShellLiteral(value) { - return `'${value.replaceAll("'", "''")}'` -} - -function readIfPresent(path) { - return existsSync(path) ? readFileSync(path, 'utf8') : '' -} - -function fileSha256(path) { - return createHash('sha256').update(readFileSync(path)).digest('hex') -} diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 2ceb821f8a3e..df8abfdf147d 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -20,7 +20,6 @@ "../src/preload/**/*", "../src/shared/**/*", "../src/packages/process-host/src/__fixtures__/**/*", - "../src/relay/**/*", "../src/wsl-guest/**/*", "../src/types/**/*" ], diff --git a/config/tsconfig.relay.json b/config/tsconfig.relay.json deleted file mode 100644 index 7951c1ada46c..000000000000 --- a/config/tsconfig.relay.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": [ - "../src/relay/**/*", - "../src/wsl-guest/**/*", - "../src/main/pty/overlay-mirror.ts", - "../src/main/pty/shell-startup-env.ts" - ], - "exclude": ["../src/relay/integration.test.ts"], - "compilerOptions": { - "composite": true, - "rootDir": "../src", - "outDir": "../out/relay" - } -} diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index 7bec67891725..8c0b623d4c74 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -13,7 +13,6 @@ const contracts = [ 'src/main/codex-usage/scanner-incremental-append.test.ts', 'src/main/usage/jsonl-file-reader.test.ts', 'src/main/usage/jsonl-file-snapshot.test.ts', - 'src/relay/fs-path-metadata-symlink-concurrency.test.ts', 'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts', 'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts', 'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts', diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 302611ab2bc6..9c0e49f6455c 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 102m + + downloads: 103m @@ -15,7 +15,7 @@ downloads downloads - 102m - 102m + 103m + 103m diff --git a/docs/audits/sync-notes-2026-10-11b.md b/docs/audits/sync-notes-2026-10-11b.md new file mode 100644 index 000000000000..c64c6258ff86 --- /dev/null +++ b/docs/audits/sync-notes-2026-10-11b.md @@ -0,0 +1,96 @@ +# Sync notes: stablyai/orca `main` into `fork-main` (2026-10-11b) + +Branch: `sync/upstream-2026-10-11b` from `origin/fork-main` (`079420eab9d`). +Merge: `upstream/main` @ `92fb883cf25` (`git merge --no-ff`). +At launch, fork-main was 105 behind / 626 ahead of upstream/main. + +This is a follow-up to PR #17 (`sync/upstream-2026-10-11`). Do not merge this +PR. Do not cut tags. Do not touch Coolify. + +## Policy + +- Keep fork features listed in `config/fork-features.yml`. +- Re-apply #15/#17 resolutions where upstream re-touched the same files. +- Upstream wins for files the fork does not customize. +- Allowlisted auto-resolves: `.gitignore` union (drop leftover orphan comments). + +## Conflict resolutions (7 paths) + +### `.gitignore` + +Union of ours and theirs. Dropped two orphan comment lines left from the +previous union (`# Local-only design/planning docs…` fragments). Kept +upstream's new ignores: + +- `.build/preflight-benchmark/` +- `.local/static-analysis-research/` + +### `config/scripts/pr-e2e-gate-contract.test.mjs` + +Took upstream. Upstream removed the Docker-SSH source-routing tests along with +`hasSshSourceChange`, `SSH_SOURCE_ROUTE_IDS`, and the +`ssh-docker-watcher-isolation` job (`ff27338299e`). The fork-only edit in those +tests was `changed-e2e` timeout `>= 45` → `>= 60`; that assertion lived only +inside the deleted tests, so it cannot stay. The 60-minute shard still lives in +the workflow. + +### `src/shared/protocol-version.ts` + +Kept the fork spread `...FORK_TERMINAL_AND_AGENT_LAUNCH_RUNTIME_CAPABILITIES` +(terminal navigation/question + `automation.agent-flags.v1` + upstream +`AGENT_LAUNCH_RUNTIME_CAPABILITIES`). Added upstream +`SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY` (`ssh.target-management.v1`). + +### `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx` + +Kept the fork `local?: boolean` in-app picker (Linux GTK-less browse). Kept +optional `targetId` + `runtimeEnvironmentId` so both may be set (upstream: +server's own SSH host). Did not take upstream's exclusive union, because +`CreateProjectLocationField` passes `local={local}` with possibly undefined +ids. + +### `src/renderer/src/components/sidebar/use-remote-file-browser-listing.ts` + +Combined: + +1. `local` → `repos.browseLocalDirectory` (fork) +2. both ids → `browseRuntimeSshDirectory` (upstream, server dials its SSH host) +3. `targetId` only → `ssh.browseDir` (this client's SSH host) +4. otherwise → `browseRuntimeServerDirectory` + +### `src/renderer/src/components/sidebar/use-remote-file-browser-listing.test.ts` + +Add/add: kept upstream happy-dom `renderHook` cases for server-owned SSH vs +client SSH vs server disk, plus the fork local-directory case. + +### `tests/e2e/helpers/electron-process-shutdown.unit.test.ts` + +Kept both: + +- fork `readDaemonPidFiles` JSON/legacy pid-file cases +- upstream POSIX `cleanupE2EDaemons` SIGTERM/SIGKILL/descendant cases + +## Auto-merged fork semantics kept + +- `pet: 'host'` in `HOST_OPERATION_OWNERSHIP` (covers `pet.speak.*`) +- `AUTOMATION_AGENT_FLAGS_RUNTIME_CAPABILITY` in + `automation-runtime-capabilities.ts` and the fork protocol spread +- automation-run open/resume/workspace-action (no upstream commits on those + files in this 105-commit window; auto-merge kept fork) + +## Light local checks (this box, via `heavy-run`, glibc Node 22) + +- `tsc --noEmit -p config/tsconfig.tc.cli.json`: clean. +- vitest `--maxWorkers=1`: + - `pr-e2e-gate-contract.test.mjs` + `host-operation-ownership.test.ts`: 29 passed + - `electron-process-shutdown.unit.test.ts` + `repo.test.ts`: 34 passed + - `runtime-ssh-target-management.test.ts`: 13 passed + - `use-remote-file-browser-listing.test.ts`: local transform failed to resolve `@/runtime/...` aliases (same class of local alias miss as `AddRepoSteps.default-checkout.test.ts` → `@/i18n/i18n`). Not treated as a merge defect; GitHub CI runs the full vitest config. +- Full `pnpm tc` (node+web) and electron-builder not run locally. + +## CI follow-up + +`test / tests node 24 5/5` failed on `node-server-change-scope.test.mjs` (7 cases). +Cause: upstream `#27215` removed `src/relay/` from `CROSS_HOST_PREFIXES` and did not +move the change-scope fixtures off `src/relay/…`. Pointed those fixtures at +`src/main/ssh/` (still a cross-host prefix). Local vitest: 98 passed. diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index 25ad464f0028..7b11d097f533 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -94,7 +94,7 @@ On the computer you want to use as the client: caption="Share an Orca desktop runtime with another client over a private network." /> -Adding a server saves it without forcing every new project onto it. Open **Advanced → Active Server** only when you want server-routed projects, terminals, provider checks, and browser or mobile handoff to use that server by default. +Adding a server saves it without forcing every new project onto it. Open **Advanced → Default host for new projects** only when you want server-routed projects, terminals, provider checks, and browser or mobile handoff to use that server by default. ## Use the remote server diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts index 13d5c845b375..8a3621be8f8b 100644 --- a/mobile/src/tasks/composer-linked-work-item.ts +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -50,7 +50,11 @@ export function buildLinearLinkedWorkItem(issue: { // Faithful port of desktop applyLinkedWorkItem's name gate: the derived name // replaces the current field only when it's empty, still the last auto-name, or // a lookup query — never a name the user deliberately typed. -export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { +export function shouldApplyAutoName(args: { + currentName: string + lastAutoName: string + lookupTextIsQuery?: boolean +}): boolean { return shouldApplyWorkspaceSourceAutoName(args) } diff --git a/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts b/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts new file mode 100644 index 000000000000..270a0439eba8 --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts @@ -0,0 +1,123 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' +import type { LinearIssue } from '../../../src/shared/linear/issue-types' +import { useMobileComposerSource } from './use-mobile-composer-source' +import { buildTaskWorkspaceCreateParams } from './workspace-create-params' + +const mounted: ReactTestRenderer[] = [] +afterEach(() => { + act(() => { + for (const renderer of mounted.splice(0)) { + renderer.unmount() + } + }) +}) + +function renderMobileComposerSource() { + const holder: { value?: ReturnType } = {} + function Probe() { + holder.value = useMobileComposerSource({ client: null, selectedRepoId: 'fixture-repo' }) + return null + } + act(() => { + mounted.push(create(createElement(Probe))) + }) + return { + get current() { + if (!holder.value) { + throw new Error('Composer source did not render') + } + return holder.value + } + } +} + +function issue(number: number): GitHubWorkItem { + return { + id: `issue-${number}`, + type: 'issue', + number, + title: 'Fix export', + state: 'open', + url: `https://github.com/fixture/repo/issues/${number}`, + repoId: 'fixture-repo', + labels: [], + updatedAt: '', + author: null + } +} + +describe('mobile workspace name ownership', () => { + const linearIssue: LinearIssue = { + id: 'linear-9', + identifier: 'ENG-9', + title: 'Fix export', + url: 'https://linear.app/fixture/issue/ENG-9', + state: { name: 'Open', type: 'unstarted', color: '' }, + team: { id: 'engineering', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '' + } + + it('keeps a manual Linear identifier name when its source refreshes', () => { + const result = renderMobileComposerSource() + act(() => result.current.handleSmartLinearIssueSelect(linearIssue)) + act(() => result.current.setName('ENG-9')) + act(() => + result.current.handleSmartLinearIssueSelect({ ...linearIssue, title: 'Fix refreshed export' }) + ) + + expect(result.current.name).toBe('ENG-9') + expect(result.current.isNameAutoManaged).toBe(false) + }) + + it('still replaces an unselected Linear identifier query', () => { + const result = renderMobileComposerSource() + act(() => result.current.setName('ENG-9')) + act(() => result.current.handleSmartLinearIssueSelect(linearIssue)) + + expect(result.current.name).toBe('eng-9-fix-export') + expect(result.current.isNameAutoManaged).toBe(true) + }) + + it.each([2, 347])('keeps an edited name when issue #%s replaces the source', (number) => { + const result = renderMobileComposerSource() + act(() => result.current.handleSmartGitHubItemSelect(issue(2))) + act(() => result.current.setName('002')) + act(() => result.current.handleSmartGitHubItemSelect(issue(number))) + + expect(result.current.name).toBe('002') + expect(result.current.isNameAutoManaged).toBe(false) + expect( + buildTaskWorkspaceCreateParams({ + item: { + provider: 'github', + source: { ...issue(number), type: 'issue', repoId: 'fixture-repo' } + }, + targetRepoId: 'fixture-repo', + setupDecision: 'skip', + agent: 'blank', + workspaceName: result.current.name, + nameIsAutoManaged: result.current.isNameAutoManaged + }) + ).toMatchObject({ + name: '002', + displayName: '002', + displayNameKind: 'user', + linkedIssue: number + }) + }) + + it.each(['002', '347', '#347'])('still generates a name when picking query %s', (query) => { + const result = renderMobileComposerSource() + act(() => result.current.setName(query)) + act(() => result.current.handleSmartGitHubItemSelect(issue(query === '002' ? 2 : 347))) + + expect(result.current.name).toBe('fix-export') + expect(result.current.isNameAutoManaged).toBe(true) + }) +}) diff --git a/mobile/src/tasks/use-mobile-composer-name.ts b/mobile/src/tasks/use-mobile-composer-name.ts new file mode 100644 index 000000000000..26dfe4872e4c --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-name.ts @@ -0,0 +1,27 @@ +import { useCallback, useRef, useState } from 'react' +import { shouldApplyAutoName } from './composer-linked-work-item' +import type { MobileLinkedWorkItem } from './mobile-composer-source-types' + +export function useMobileComposerName(linkedWorkItem: MobileLinkedWorkItem | null) { + const [name, setNameState] = useState('') + const lastAutoNameRef = useRef('') + const setName = useCallback((value: string) => setNameState(value), []) + const applyAutoName = useCallback( + (suggested: string, currentName: string) => { + if ( + suggested && + shouldApplyAutoName({ + currentName, + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem + }) + ) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + }, + [linkedWorkItem] + ) + const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current + return { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged } +} diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts index 5d6c373d454c..e3bb9c02518b 100644 --- a/mobile/src/tasks/use-mobile-composer-source.ts +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -28,6 +28,7 @@ import type { MobileLinkedWorkItem, SmartNameSelection } from './mobile-composer-source-types' +import { useMobileComposerName } from './use-mobile-composer-name' const EMPTY_BASE: ComposerBaseState = {} export type UseMobileComposerSourceArgs = { @@ -39,7 +40,6 @@ export type UseMobileComposerSourceArgs = { export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { const { client, selectedRepoId, worktreeBranches = [], onError } = args - const [name, setNameState] = useState('') const [linkedWorkItem, setLinkedWorkItem] = useState(null) const [base, setBase] = useState(EMPTY_BASE) const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) @@ -50,19 +50,12 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { // may contain slashes) is kept verbatim as the git branch (folder is sanitized). const [branchCreateIntent, setBranchCreateIntent] = useState(false) - const lastAutoNameRef = useRef('') const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null) // Guards async base resolution: only the latest selection applies its result. const resolveTokenRef = useRef(0) - const setName = useCallback((value: string) => setNameState(value), []) - - const applyAutoName = useCallback((suggested: string, currentName: string) => { - if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) { - setNameState(suggested) - lastAutoNameRef.current = suggested - } - }, []) + const { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged } = + useMobileComposerName(linkedWorkItem) const clearBaseAndBranch = useCallback(() => { branchSelectionRef.current = null @@ -201,18 +194,23 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { resolveTokenRef.current += 1 setLinkedWorkItem(buildLinearLinkedWorkItem(issue)) const suggested = resolveLinearAutoName(issue) - const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase() + const identifierTyped = + !linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase() if ( suggested && (identifierTyped || - shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current })) + shouldApplyAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem + })) ) { setNameState(suggested) lastAutoNameRef.current = suggested } clearBaseAndBranch() }, - [clearBaseAndBranch, name] + [clearBaseAndBranch, linkedWorkItem, name] ) const handleSmartBranchSelect = useCallback( @@ -302,10 +300,6 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { [base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch] ) - // Auto-managed until the user edits the name away from the last derived value; - // desktop suppresses the workspace displayName once the name is user-edited. - const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current - return { name, setName, diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 324dba707da2..4d0428d9691c 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -4008,11 +4008,11 @@ private final class SocketListener: @unchecked Sendable { } let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true let decoder = JSONDecoder() - while let line = readLine(from: fd) { + AgentConnectionRequestLoop.run(readLine: { readLine(from: fd) }) { line in guard let data = line.data(using: .utf8), let request = try? decoder.decode(Request.self, from: data) else { - continue + return true } if !registeredSession && isAuthenticatedAgentSession( expectedToken: token, @@ -4029,7 +4029,7 @@ private final class SocketListener: @unchecked Sendable { ) } catch { fputs("computer-use owner monitor failed: \(error)\n", stderr) - return + return false } sessionLock.lock() let registration = sessionOwnership.registerConnection( @@ -4039,7 +4039,7 @@ private final class SocketListener: @unchecked Sendable { sessionLock.unlock() guard registration != .rejected else { monitor.cancel() - return + return false } registeredSession = true hangupMonitor = monitor @@ -4056,6 +4056,7 @@ private final class SocketListener: @unchecked Sendable { authorizedPeer: authorizedPeer ) writeJSON(response, to: fd) + return true } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentConnectionRequestLoop.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentConnectionRequestLoop.swift new file mode 100644 index 000000000000..b5eeab03a90c --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentConnectionRequestLoop.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum AgentConnectionRequestLoop { + /// Serves request lines until `readLine` returns nil or `handle` returns false. + public static func run(readLine: () -> String?, handle: (String) -> Bool) { + var keepServing = true + while keepServing { + // Why: a connection thread outlives its requests; without a per-request pool, objects + // autoreleased while encoding a screenshot keep its WindowServer buffer until disconnect. + keepServing = autoreleasepool { + guard let line = readLine() else { return false } + return handle(line) + } + } + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentConnectionRequestLoopTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentConnectionRequestLoopTests.swift new file mode 100644 index 000000000000..2218581bc5df --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentConnectionRequestLoopTests.swift @@ -0,0 +1,50 @@ +import Foundation +@testable import OrcaComputerUseMacOSCore +import XCTest + +final class AgentConnectionRequestLoopTests: XCTestCase { + func testReleasesARequestsAutoreleasedObjectsBeforeTheNextRequest() { + var lines = ["screenshot", "next"] + weak var screenshotBuffer: NSObject? + var releasedBeforeNextRequest: Bool? + + AgentConnectionRequestLoop.run(readLine: { lines.isEmpty ? nil : lines.removeFirst() }) { line in + if line == "screenshot" { + let buffer = NSObject() + screenshotBuffer = buffer + // Why: stands in for the objects AppKit PNG encoding leaves in the pool. + _ = Unmanaged.passRetained(buffer).autorelease() + } else { + releasedBeforeNextRequest = screenshotBuffer == nil + } + return true + } + + XCTAssertEqual(releasedBeforeNextRequest, true) + } + + func testServesEveryLineUntilInputEnds() { + var lines = ["a", "b", "c"] + var handled: [String] = [] + + AgentConnectionRequestLoop.run(readLine: { lines.isEmpty ? nil : lines.removeFirst() }) { line in + handled.append(line) + return true + } + + XCTAssertEqual(handled, ["a", "b", "c"]) + } + + func testStopsReadingWhenTheHandlerClosesTheConnection() { + var lines = ["a", "b", "c"] + var handled: [String] = [] + + AgentConnectionRequestLoop.run(readLine: { lines.isEmpty ? nil : lines.removeFirst() }) { line in + handled.append(line) + return line != "b" + } + + XCTAssertEqual(handled, ["a", "b"]) + XCTAssertEqual(lines, ["c"]) + } +} diff --git a/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs index 00282fe455ee..ab513dea240e 100644 --- a/native/windows-cli-launcher/src/main.rs +++ b/native/windows-cli-launcher/src/main.rs @@ -9,16 +9,29 @@ //! user-writable directory that mutates environment variables and proxies a //! child process is the shape antivirus MSIL heuristics are trained on, and it //! was flagged as exactly that across several vendors (#23383). +//! +//! On a Windows server host, orcad copies this binary into its profile and +//! writes `orca-launcher.cfg` beside it; that file selects the server mode. use std::env; +use std::fs; use std::path::{Path, PathBuf}; use std::process::{exit, Command}; +const SERVER_CONFIG_FILENAME: &str = "orca-launcher.cfg"; + fn main() { let launcher = match env::current_exe() { Ok(path) => path, Err(error) => fail(&format!("Unable to start the Orca CLI: {error}")), }; + if let Some(config) = launcher + .parent() + .map(|directory| directory.join(SERVER_CONFIG_FILENAME)) + .filter(|config| config.is_file()) + { + run_server_cli(&config); + } let Some(resources_directory) = launcher.parent().and_then(Path::parent) else { fail(&format!( @@ -74,10 +87,57 @@ fn main() { }, ); + run_cli(&electron_path, &cli_path) +} + +/// The server mode: the pinned Node runs the active slot's CLI under the +/// server's profile, as the POSIX launcher does. +fn run_server_cli(config_path: &Path) -> ! { + let config = fs::read_to_string(config_path).unwrap_or_else(|error| { + fail(&format!( + "Unable to read the Orca CLI configuration at \"{}\": {error}", + config_path.display() + )) + }); + let node_path = PathBuf::from(config_value(&config, "node", config_path)); + let cli_path = PathBuf::from(config_value(&config, "cli", config_path)); + let user_data_path = config_value(&config, "user-data", config_path); + for path in [&node_path, &cli_path] { + if !path.is_file() { + fail(&format!( + "Unable to locate \"{}\"; reconnect to the Orca server to refresh the CLI", + path.display() + )); + } + } + env::set_var("ORCA_USER_DATA_PATH", user_data_path); + for name in ["NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", "ELECTRON_RUN_AS_NODE"] { + env::remove_var(name); + } + run_cli(&node_path, &cli_path) +} + +fn config_value<'a>(config: &'a str, key: &str, config_path: &Path) -> &'a str { + config + .lines() + .find_map(|line| { + line.split_once('=') + .filter(|(name, value)| *name == key && !value.is_empty()) + .map(|(_, value)| value) + }) + .unwrap_or_else(|| { + fail(&format!( + "The Orca CLI configuration at \"{}\" has no {key}", + config_path.display() + )) + }) +} + +fn run_cli(program: &Path, cli_path: &Path) -> ! { // Each argument stays its own argv entry, so a body holding newlines reaches // the CLI intact. - let mut command = Command::new(&electron_path); - command.arg(&cli_path).args(env::args_os().skip(1)); + let mut command = Command::new(program); + command.arg(cli_path).args(env::args_os().skip(1)); match command.status() { Ok(status) => exit(status.code().unwrap_or(1)), diff --git a/package.json b/package.json index f4873e27f119..34dd36629583 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "test": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/run-vitest.mjs run --config config/vitest.config.ts", "test:node": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:node-server": "node config/scripts/run-node-server-tests.mjs", - "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", + "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", @@ -77,6 +77,7 @@ "typecheck:web": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json", "typecheck:e2e": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.e2e.json", "typecheck": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs", + "typecheck:ci": "pnpm run build:packages && node config/scripts/run-typecheck-projects-in-parallel.mjs --compiler=bun", "typecheck:tsc:node": "pnpm run build:packages && node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json --composite false", "typecheck:tsc:cli": "pnpm run build:packages && tsc --noEmit -p config/tsconfig.cli.json --composite false", "typecheck:tsc:web": "pnpm run build:packages && tsc --noEmit -p config/tsconfig.web.json --composite false", @@ -149,18 +150,10 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", - "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", - "test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs", - "test:e2e:ssh-docker": "node config/scripts/run-ssh-docker-e2e.mjs", - "test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs", - "test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs", - "test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs", - "test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs", "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tests/tools/win-update-e2e/run.mjs", "win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs", "smoke:windows-pty-native-capability": "node tests/tools/windows-pty-native-capability-smoke/run.mjs", - "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", @@ -186,7 +179,6 @@ "bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs", "test:e2e:remote-bulk-open-freeze": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/remote-session-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "test:e2e:ssh-docker-bulk-open-freeze": "node config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs", "repro:live-remote-bulk-open-freeze": "node config/scripts/live-remote-bulk-open-freeze-repro.mjs", "repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs", "audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f4564f78941..4993af9b4d4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,7 @@ patchedDependencies: i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 node-pty@1.1.0: 92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192 + oxlint-plugin-anti-slop@0.1.2: c442be3f300c2d47e98af4c6347fc18cad8c13cc18913c4ee50b3720dad85753 ws@8.22.0: 9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14 importers: @@ -512,7 +513,7 @@ importers: version: 1.86.0(oxlint-tsgolint@7.0.2003) oxlint-plugin-anti-slop: specifier: github:dmmulroy/anti-slop#c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b - version: https://codeload.github.com/dmmulroy/anti-slop/tar.gz/c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b + version: https://codeload.github.com/dmmulroy/anti-slop/tar.gz/c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b(patch_hash=c442be3f300c2d47e98af4c6347fc18cad8c13cc18913c4ee50b3720dad85753) oxlint-plugin-react-doctor: specifier: 0.9.14 version: 0.9.14 @@ -14166,7 +14167,7 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.71.0 '@oxfmt/binding-win32-x64-msvc': 0.71.0 - oxlint-plugin-anti-slop@https://codeload.github.com/dmmulroy/anti-slop/tar.gz/c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b: + oxlint-plugin-anti-slop@https://codeload.github.com/dmmulroy/anti-slop/tar.gz/c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b(patch_hash=c442be3f300c2d47e98af4c6347fc18cad8c13cc18913c4ee50b3720dad85753): dependencies: '@oxlint/plugins': 1.78.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 226a7ef9c1ce..37856775c643 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -65,3 +65,4 @@ patchedDependencies: '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch ws@8.22.0: config/patches/ws@8.22.0.patch + oxlint-plugin-anti-slop@0.1.2: config/patches/oxlint-plugin-anti-slop@0.1.2.patch diff --git a/src/cli/command-suggestion-budget.test.ts b/src/cli/command-suggestion-budget.test.ts index 7902ad7fdba7..d2d1d7256f58 100644 --- a/src/cli/command-suggestion-budget.test.ts +++ b/src/cli/command-suggestion-budget.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as distance from '../shared/edit-distance' import { suggestCommands, unknownFlagData } from './command-suggestion' import type { CommandSpec } from './command-spec' +import { COMMAND_SPECS } from './specs' const specs: CommandSpec[] = [ { path: ['list'], summary: '', usage: '', allowedFlags: [] }, @@ -17,6 +18,12 @@ describe('suggestion distance work', () => { expect(spy).not.toHaveBeenCalled() }) + it('keeps the live destructive alias registry out of long-input distance work', () => { + const spy = vi.spyOn(distance, 'levenshtein') + expect(suggestCommands(COMMAND_SPECS, ['emulator', 'x'.repeat(32_768)])).toEqual([]) + expect(spy).not.toHaveBeenCalled() + }) + it('does no distance calculations for a long flag but still lists valid flags', () => { const spy = vi.spyOn(distance, 'levenshtein') expect(unknownFlagData('x'.repeat(32_768), ['worktree', 'json'])).toEqual({ diff --git a/src/cli/command-suggestion-destructive-families.test.ts b/src/cli/command-suggestion-destructive-families.test.ts new file mode 100644 index 000000000000..110d2490b56f --- /dev/null +++ b/src/cli/command-suggestion-destructive-families.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { + findCommandSpec, + normalizeCommandPositionals, + parseArgs, + validateCommandAndFlags +} from './args' +import { specPaths, type CommandSpec } from './command-spec' +import { suggestCommands, unknownCommandData } from './command-suggestion' +import { COMMAND_SPECS } from './specs' + +const blocked = [ + ['orchestration resume', 'orchestration reset'], + ['orchestration rerun', 'orchestration reset'], + ['orchestration repl', 'orchestration reset'], + ['artifacts deset', 'artifacts delete'], + ['emulator ball', 'emulator kill'], + ['emulator showdown', 'emulator shutdown'], + ['orchestration worker-showw', 'orchestration worker-stop'], + ['orchestration worker-state', 'orchestration worker-stop'], + ['orchestration worker-relay', 'orchestration worker-release'], + ['orchestration worker-reuse', 'orchestration worker-release'], + ['tab clues', 'tab close'], + ['linear relation move', 'linear relation remove'], + ['environment rollbook', 'environment rollback'] +] + +const recovery = [ + ['orchestration rese', 'orchestration reset'], + ['orchestration rest', 'orchestration reset'], + ['emulator kil', 'emulator kill'], + ['emulator shutdow', 'emulator shutdown'], + ['orchestration worker-stp', 'orchestration worker-stop'], + ['orchestration worker-releasee', 'orchestration worker-release'], + ['tab clos', 'tab close'], + ['tab clone', 'tab close'], + ['environment rollbac', 'environment rollback'], + ['environment remov', 'environment rm'], + ['artifacts delet', 'artifacts delete'], + ['worktree remov', 'worktree remove'], + ['worktree delet', 'worktree delete'], + ['orchestration chek', 'orchestration check'], + ['linear relation remov', 'linear relation remove'], + ['linear label remov', 'linear label remove'], + ['capture stp', 'capture stop'], + ['clea', 'clear'] +] + +describe('live destructive command recovery', () => { + it.each(blocked)('does not turn %s into %s', (input, destructive) => { + const path = input.split(' ') + const data = unknownCommandData(COMMAND_SPECS, path) + expect(data.suggestions).not.toContain(destructive) + expect(data.nextSteps.join(' ')).not.toContain(`orca ${destructive}`) + const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(path)) + expect(() => validateCommandAndFlags(COMMAND_SPECS, parsed)).toThrow('Unknown command') + }) + + it.each(recovery)('still recovers %s as %s', (input, expected) => { + const data = unknownCommandData(COMMAND_SPECS, input.split(' ')) + expect(data.suggestions).toContain(expected) + expect(data.nextSteps.join(' ')).toContain(`orca ${expected}`) + }) + + it('retains the complete legitimate environment removal ranking', () => { + expect(suggestCommands(COMMAND_SPECS, ['environment', 'remov'])).toEqual([ + 'environment recover', + 'environment rm' + ]) + }) + + it('keeps every declared alias accepted and normalized to its canonical command', () => { + for (const spec of COMMAND_SPECS) { + for (const path of specPaths(spec)) { + expect(findCommandSpec(COMMAND_SPECS, path)).toBe(spec) + const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(path)) + expect(parsed.commandPath).toEqual(spec.path) + } + } + }) +}) + +function destructiveSpec(path: string[], aliases?: string[][]): CommandSpec { + return { path, aliases, destructive: true, summary: '', usage: '', allowedFlags: [] } +} + +describe('declared destructive alias families', () => { + const deletion = destructiveSpec( + ['worktree', 'rm'], + [ + ['worktree', 'remove'], + ['worktree', 'delete'] + ] + ) + const reset = destructiveSpec(['orchestration', 'reset']) + + it('does not let intent for reset unlock a deletion candidate', () => { + const specs = [deletion, reset, destructiveSpec(['artifacts', 'delete'])] + expect(suggestCommands(specs, ['artifacts', 'deset'])).toEqual([]) + }) + + it('shares only declared equivalent verbs with another command', () => { + const specs = [deletion, reset, destructiveSpec(['environment', 'rm'])] + expect(suggestCommands(specs, ['environment', 'remov'])).toEqual(['environment rm']) + expect(suggestCommands(specs, ['environment', 'rese'])).toEqual([]) + }) + + it('merges overlapping declarations transitively regardless of registry order', () => { + const specs = [ + destructiveSpec(['one', 'read'], [['one', 'redo']]), + destructiveSpec(['two', 'redo'], [['two', 'undo']]), + destructiveSpec(['three', 'undo']) + ] + expect(suggestCommands(specs, ['three', 'red'])).toEqual(['three undo']) + expect(suggestCommands(specs.toReversed(), ['three', 'red'])).toEqual(['three undo']) + }) + + it('does not derive destructive equivalence from ordinary command aliases', () => { + const specs: CommandSpec[] = [ + destructiveSpec(['state', 'undo']), + { + path: ['inspect', 'read'], + aliases: [['inspect', 'undo']], + summary: '', + usage: '', + allowedFlags: [] + } + ] + expect(suggestCommands(specs, ['state', 'red'])).toEqual([]) + }) + + it('keeps ranking, deduplication, depth, and the three-result limit', () => { + const specs = [ + destructiveSpec(['state', 'reset']), + ...['rest', 'resee', 'reses', 'resets'].map((verb) => ({ + path: ['state', verb], + summary: '', + usage: '', + allowedFlags: [] + })), + { path: ['state', 'rest'], summary: '', usage: '', allowedFlags: [] }, + { ...destructiveSpec(['state', 'rese']), hidden: true } + ] + expect(suggestCommands(specs, ['state', 'rese'])).toEqual([ + 'state resee', + 'state reses', + 'state reset' + ]) + expect(suggestCommands(specs, ['state', 'rese', 'extra'])).toEqual([]) + }) +}) diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index dcdbed780ab6..47c78d74119a 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -15,26 +15,24 @@ function finalToken(path: string[]): string { return path.at(-1) ?? '' } -// Why: destructiveness is declared on the spec (single source of truth); the -// intent verbs are the final tokens of every destructive path/alias so the guard -// tracks the registry instead of a hand-maintained list. -function destructiveVerbs(specs: CommandSpec[]): Set { - const verbs = new Set() +// Why: declared aliases connect equivalent destructive verbs without unlocking unrelated actions. +function destructiveVerbFamilies(specs: CommandSpec[]): Map> { + const families = new Map>() for (const spec of specs) { - if (spec.destructive) { - for (const path of specPaths(spec)) { - verbs.add(finalToken(path)) - } + if (!spec.destructive) { + continue + } + const verbs = specPaths(spec).map(finalToken) + const family = new Set(verbs.flatMap((verb) => [...(families.get(verb) ?? [verb])])) + for (const verb of family) { + families.set(verb, family) } } - return verbs + return families } -// Why: deletion is irreversible and suggestions flow into agents' recovery -// channel (--json nextSteps), so only unlock destructive candidates when the -// input token is itself a near-miss of a destructive verb. #6303 -function intendsDestruction(inputToken: string, verbs: Set): boolean { - for (const verb of verbs) { +function intendsDestruction(inputToken: string, verbs: Set | undefined): boolean { + for (const verb of verbs ?? []) { if ( Math.abs(inputToken.length - verb.length) <= DESTRUCTIVE_INTENT_THRESHOLD && levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD @@ -62,16 +60,17 @@ function rankByDistance(scored: { label: string; distance: number }[]): string[] // Why: same-depth matching avoids suggesting parent groups or unrelated commands. export function suggestCommands(specs: CommandSpec[], commandPath: string[]): string[] { const input = commandPath.join(' ') - // Why: only surface destructive commands when the user actually reached for one; - // otherwise a benign typo could recover into an irreversible action. #6303 - const allowDestructive = intendsDestruction(finalToken(commandPath), destructiveVerbs(specs)) + const families = destructiveVerbFamilies(specs) const seen = new Set() const scored: { label: string; distance: number }[] = [] for (const spec of specs) { if (spec.hidden) { continue } - if (spec.destructive && !allowDestructive) { + if ( + spec.destructive && + !intendsDestruction(finalToken(commandPath), families.get(finalToken(spec.path))) + ) { continue } const candidates = specPaths(spec).map((path) => diff --git a/src/cli/handlers/managed-server.test.ts b/src/cli/handlers/managed-server.test.ts index ceecb0db32e3..57d9b35c8cb3 100644 --- a/src/cli/handlers/managed-server.test.ts +++ b/src/cli/handlers/managed-server.test.ts @@ -111,7 +111,8 @@ describe('managed server CLI verbs', () => { outcome: 'refused', verdict: 'live', code: 'orcad_stop_active_environment', - reason: 'Choose another Active Server in Advanced before stopping this server.' + reason: + 'This server is the default host for new projects. Choose another one in Advanced before stopping it.' } await expect(run('environment stop', client(refusal), [['yes', true]])).rejects.toMatchObject({ code: 'managed_server_refused', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 259fce08ccab..e0ec54b26434 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -310,6 +310,39 @@ describe('unknown command surfaces a suggestion', () => { expect(stderr).toContain('orca worktree') }) + it.each([ + ['orchestration resume', 'orchestration reset'], + ['artifacts deset', 'artifacts delete'], + ['emulator ball', 'emulator kill'], + ['emulator showdown', 'emulator shutdown'], + ['orchestration worker-showw', 'orchestration worker-stop'], + ['orchestration worker-relay', 'orchestration worker-release'], + ['tab clues', 'tab close'], + ['environment rollbook', 'environment rollback'], + ['linear relation move', 'linear relation remove'] + ])('keeps %s recovery away from %s before runtime lookup', async (input, forbidden) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + runtimeClientConstructorMock.mockClear() + try { + await main([...input.split(' '), '--json'], '/tmp/repo') + expect(process.exitCode).toBe(1) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('invalid_argument') + expect(output).toContain('"runtimeId": null') + expect(output).not.toContain(`orca ${forbidden}`) + expect(output).not.toContain(`"${forbidden}"`) + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + } finally { + logSpy.mockRestore() + } + + await main(input.split(' '), '/tmp/repo') + expect(errorSpy.mock.calls.flat().join('\n')).not.toContain(`orca ${forbidden}`) + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + }) + it('reports a mistyped pre-command flag without swallowing the command', async () => { await main(['--jso', 'worktree', 'list'], '/tmp/repo') diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 3125f2f38644..d03d8e5fe6a7 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -245,6 +245,7 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['tab', 'close'], + destructive: true, summary: 'Close a browser tab', usage: 'orca tab close [--index ] [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree'] diff --git a/src/cli/specs/emulator.ts b/src/cli/specs/emulator.ts index 42f1087d4086..5b8ac913e48e 100644 --- a/src/cli/specs/emulator.ts +++ b/src/cli/specs/emulator.ts @@ -65,12 +65,14 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['emulator', 'kill'], + destructive: true, summary: 'Stop helper for device', usage: 'orca emulator kill [--device ] [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'device', 'emulator', 'worktree'] }, { path: ['emulator', 'shutdown'], + destructive: true, summary: 'Stop helper and shut down the simulator device', usage: 'orca emulator shutdown [--device ] [--emulator ] [--worktree ] [--json]', diff --git a/src/cli/specs/linear-mcp.ts b/src/cli/specs/linear-mcp.ts index af7f654e0a32..531a41813781 100644 --- a/src/cli/specs/linear-mcp.ts +++ b/src/cli/specs/linear-mcp.ts @@ -92,6 +92,7 @@ export const LINEAR_MCP_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['linear', 'relation', 'remove'], + destructive: true, aliases: [['linear', 'relation', 'rm']], summary: 'Remove a Linear issue relation', usage: diff --git a/src/cli/specs/managed-server.ts b/src/cli/specs/managed-server.ts index eb28ac439504..9a73b0896660 100644 --- a/src/cli/specs/managed-server.ts +++ b/src/cli/specs/managed-server.ts @@ -29,6 +29,7 @@ export const MANAGED_SERVER_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['environment', 'rollback'], + destructive: true, summary: 'Roll a managed Orca server back to its previous version', usage: 'orca environment rollback --environment [--json]', allowedFlags: [...GLOBAL_FLAGS], @@ -59,7 +60,7 @@ export const MANAGED_SERVER_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Stops orcad on the SSH host and, once the host proves it exited, removes the server from this machine. Its terminals end. Requires --yes.', 'If the host cannot prove orcad exited, the server stays linked and the refusal says why; nothing is removed on a guess. For a host that is gone for good, use `orca environment forget`.', - 'Pick another Active Server first if this one is active. `orca environment cancel-stop` withdraws a stop orcad has not acted on yet.', + 'If this server is the default host for new projects, choose another one first. `orca environment cancel-stop` withdraws a stop orcad has not acted on yet.', SELECTOR_NOTE, DESKTOP_NOTE ], diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 43674b9d7578..9123009f9de0 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -72,6 +72,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'worker-stop'], + destructive: true, summary: 'Fence one Dispatch and stop its supervised agent terminal', usage: 'orca orchestration worker-stop --dispatch [--retry-request ] [--json]', @@ -91,6 +92,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'worker-release'], + destructive: true, summary: 'Release the terminal of one settled supervised worker', usage: 'orca orchestration worker-release --dispatch [--retry-request ] [--json]', diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 5d9e621467bf..b879de5d5935 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -1,5 +1,4 @@ -import type { CommandSpec } from '../args' -import { GLOBAL_FLAGS } from '../args' +import { GLOBAL_FLAGS, type CommandSpec } from '../args' import { ORCHESTRATION_WORKER_COMMAND_SPECS } from './orchestration-worker-specs' export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ @@ -293,6 +292,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'reset'], + destructive: true, summary: 'Reset one explicit orchestration state scope', usage: 'orca orchestration reset (--all | --tasks | --messages) [--retry-request ] [--json]', diff --git a/src/main/agent-hooks/agent-home-login-shell.test.ts b/src/main/agent-hooks/agent-home-login-shell.test.ts new file mode 100644 index 000000000000..a7d38b809e41 --- /dev/null +++ b/src/main/agent-hooks/agent-home-login-shell.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type ProbeResult = { code: number | null; stdout: string; timedOut: boolean } +type RunProcessStub = (spec: { program: string; args: string[] }) => Promise + +// Why: the probe spawns a real login shell and swallows every spawn failure into its fallback. +// Left unmocked this asserts the runner's scheduling latency, not the parser: on a loaded CI box +// the 8s timeout expires and the first case silently flips to the fallback path. +const probe = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal>()), + runProcess: probe.runProcess +})) + +const probeMock = probe.runProcess +const { adoptLoginShellAgentHomes, resolveLoginShellGrokHome, resolveLoginShellKiroHome } = + await import('./agent-home-login-shell') + +function stubProbeOutput(stdout: string | ((script: string) => string)): void { + probeMock.mockImplementation(async ({ args }) => ({ + code: 0, + timedOut: false, + stdout: typeof stdout === 'string' ? stdout : stdout(args[1] ?? '') + })) +} + +function stubProbeFailure(error: Error & { killed?: boolean }): void { + probeMock.mockImplementation(async () => { + if (error.killed) { + return { code: null, timedOut: true, stdout: '' } + } + throw error + }) +} + +beforeEach(() => { + probeMock.mockReset() +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe.runIf(process.platform !== 'win32')('resolveLoginShellGrokHome', () => { + it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('/srv/grok///\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {} + expect(shell).toBe('/bin/sh') + // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. + expect(args?.[0]).toBe('-c') + }) + + it('passes -lc to a login shell that supports it', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('/srv/grok\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {} + expect(shell).toBe('/bin/zsh') + expect(args?.[0]).toBe('-lc') + }) + + it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('../relative\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) + + // Why: this is the branch that made the old test flaky — pin it so a probe failure is + // an asserted fallback rather than an invisible substitution for a real answer. + it('falls back when the probe fails or times out', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) +}) + +describe.runIf(process.platform !== 'win32')('resolveLoginShellKiroHome', () => { + it('reads KIRO_HOME from the login shell', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('/srv/kiro/\n') + + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/srv/kiro') + expect(probeMock.mock.calls[0]?.[0].args[1]).toContain('printenv KIRO_HOME') + }) + + it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('\n') + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') + + stubProbeFailure(new Error('spawn failed')) + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') + }) +}) + +describe.runIf(process.platform !== 'win32')('adoptLoginShellAgentHomes', () => { + it('adopts profile-only agent homes and keeps ones already set', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput((script) => (script.includes('KIRO_HOME') ? '/srv/kiro\n' : '/srv/grok\n')) + const env: NodeJS.ProcessEnv = { GROK_HOME: '/already/grok' } + + await adoptLoginShellAgentHomes(env) + + expect(env).toEqual({ GROK_HOME: '/already/grok', KIRO_HOME: '/srv/kiro' }) + expect(probeMock).toHaveBeenCalledTimes(1) + }) + + it('leaves the environment alone when the login shell has no override', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('\n') + const env: NodeJS.ProcessEnv = {} + + await adoptLoginShellAgentHomes(env) + + expect(env).toEqual({}) + }) +}) diff --git a/src/main/agent-hooks/agent-home-login-shell.ts b/src/main/agent-hooks/agent-home-login-shell.ts new file mode 100644 index 000000000000..73a332d1ad81 --- /dev/null +++ b/src/main/agent-hooks/agent-home-login-shell.ts @@ -0,0 +1,107 @@ +import { basename } from 'node:path' +import { userInfo } from 'node:os' +import { runProcess } from '@orca/process-host' + +const AGENT_HOME_MAX_LENGTH = 4096 +const AGENT_HOME_PROBE_TIMEOUT_MS = 8_000 + +type AgentHomeEnvName = 'GROK_HOME' | 'KIRO_HOME' + +function defaultAgentHome(home: string, dirName: string): string { + return `${home.replace(/\/+$/, '') || home}/${dirName}` +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +function normalizeAgentHome(candidate: string): string | null { + if ( + candidate.length === 0 || + candidate.length > AGENT_HOME_MAX_LENGTH || + candidate !== candidate.trim() || + !candidate.startsWith('/') || + candidate.includes('\\') || + hasControlCharacter(candidate) + ) { + return null + } + return candidate.replace(/\/+$/, '') || '/' +} + +function resolveLoginShell(): string { + const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' + if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { + return '/bin/sh' + } + return candidate +} + +/** The login shell's value for `envName`, or null when unset, invalid, or unreadable. */ +async function readLoginShellAgentHome( + envName: AgentHomeEnvName, + signal?: AbortSignal +): Promise { + try { + const shell = resolveLoginShell() + const shellName = basename(shell) + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + // Why: agent PTYs start login shells, so read the same profile-derived home override. + const result = await runProcess({ + program: shell, + args: [mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`], + timeoutMs: AGENT_HOME_PROBE_TIMEOUT_MS, + maxOutputBytes: AGENT_HOME_MAX_LENGTH * 2, + ...(signal ? { signal } : {}) + }) + signal?.throwIfAborted() + if (result.timedOut || result.code !== 0) { + return null + } + return normalizeAgentHome(result.stdout.split(/\r?\n/, 1)[0] ?? '') + } catch { + signal?.throwIfAborted() + return null + } +} + +export async function resolveLoginShellGrokHome( + home: string, + signal?: AbortSignal +): Promise { + return (await readLoginShellAgentHome('GROK_HOME', signal)) ?? defaultAgentHome(home, '.grok') +} + +/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */ +export async function resolveLoginShellKiroHome( + home: string, + signal?: AbortSignal +): Promise { + return (await readLoginShellAgentHome('KIRO_HOME', signal)) ?? defaultAgentHome(home, '.kiro') +} + +/** + * A process not started from a login shell (orcad over SSH) misses profile-only agent homes, so + * its installers would write where the agents' login-shell panes never look. Never throws. + */ +export async function adoptLoginShellAgentHomes( + env: NodeJS.ProcessEnv = process.env +): Promise { + if (process.platform === 'win32') { + return + } + const names: AgentHomeEnvName[] = ['GROK_HOME', 'KIRO_HOME'] + await Promise.all( + names + .filter((name) => !env[name]) + .map(async (name) => { + const value = await readLoginShellAgentHome(name) + if (value && !env[name]) { + env[name] = value + } + }) + ) +} diff --git a/src/main/agent-hooks/managed-agent-hook-startup.ts b/src/main/agent-hooks/managed-agent-hook-startup.ts new file mode 100644 index 000000000000..c845bf4bd363 --- /dev/null +++ b/src/main/agent-hooks/managed-agent-hook-startup.ts @@ -0,0 +1,30 @@ +import { recordManagedHookInstallFailure } from './install-telemetry' +import { + installManagedAgentHooks, + resolveStartupManagedHookAction, + shouldContinueManagedHookStartup +} from './managed-agent-hook-controls' + +type StartupHookSettings = NonNullable[0]> + +/** Installs or refreshes this host's managed agent hooks once at process start; never removes. */ +export function reconcileStartupManagedAgentHooks(host: { + getSettings: () => StartupHookSettings + isQuitting: () => boolean + shouldHydrateShellPath: boolean +}): void { + // Why skip rather than remove when the off switch is set: the hook files are user-global but this + // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca + // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. + if (resolveStartupManagedHookAction(host.getSettings()) !== 'install') { + return + } + void installManagedAgentHooks(host.getSettings(), { + shouldHydrateShellPath: host.shouldHydrateShellPath, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => + shouldContinueManagedHookStartup(host.isQuitting(), host.getSettings(), agent) + }).catch((error: unknown) => + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + ) +} diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 745d5fe92a96..7e22508c10f7 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CLAUDE_HOOK_SETTINGS, OPENCLAUDE_HOOK_SETTINGS, + getManagedEventHook, getManagedLifecycleHook, - getRemoteManagedCommand as getClaudeRemoteCommand + getRemoteManagedCommand as getClaudeRemoteCommand, + getRemoteManagedEventCommand } from '../claude/hook-settings' import { getManagedCommand as getCodexCommand, @@ -65,21 +67,22 @@ function antigravityPosixCommands(path: string): string[] { ) } +function claudeFamilyBuilders(settings: typeof CLAUDE_HOOK_SETTINGS): CommandBuilders { + return { + local: (path) => [ + getManagedLifecycleHook(path, settings).command, + getManagedEventHook('PostCompact', path, settings).command + ], + remote: (path) => [ + getClaudeRemoteCommand(path), + getRemoteManagedEventCommand('PostCompact', path) + ] + } +} + const buildersByAgent = new Map([ - [ - 'claude', - { - local: (path) => [getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS).command], - remote: (path) => [getClaudeRemoteCommand(path)] - } - ], - [ - 'openclaude', - { - local: (path) => [getManagedLifecycleHook(path, OPENCLAUDE_HOOK_SETTINGS).command], - remote: (path) => [getClaudeRemoteCommand(path)] - } - ], + ['claude', claudeFamilyBuilders(CLAUDE_HOOK_SETTINGS)], + ['openclaude', claudeFamilyBuilders(OPENCLAUDE_HOOK_SETTINGS)], [ 'qoder', { diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts similarity index 93% rename from src/main/agent-hooks/managed-hook-local-filesystem.ts rename to src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts index f299a85fda73..b19972b54cd0 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts @@ -18,8 +18,7 @@ function finish(callback: Callback, error: NodeJS.ErrnoException | null, v callback(null, value) } -/** The managed installers only need this small callback-style SFTP surface. - * On the remote host it turns hundreds of WAN round trips into local fs calls. */ +/** Runs the managed installers' callback-style SFTP surface against the local disk in tests. */ export function createManagedHookLocalFilesystem(): SFTPWrapper { const adapter = { readFile(path: string, _encoding: string, callback: Callback): void { diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts deleted file mode 100644 index d2f8d68600b6..000000000000 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - installRemoteManagedAgentHooks, - REMOTE_MANAGED_HOOK_INSTALLER_AGENTS -} from './remote-managed-hook-installers' -import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' - -const tempHomes: string[] = [] -const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' - -async function createTempHome(): Promise { - // Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries. - const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-')) - tempHomes.push(home) - return home -} - -async function listFiles(root: string): Promise { - const entries = await readdir(root, { withFileTypes: true }) - const nested = await Promise.all( - entries.map(async (entry) => { - const path = join(root, entry.name) - return entry.isDirectory() ? await listFiles(path) : [path] - }) - ) - return nested.flat() -} - -afterEach(async () => { - await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) -}) - -describe('managed-hook local filesystem', () => { - it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => { - const home = await createTempHome() - const filesystem = createManagedHookLocalFilesystem() - const options = { - grokHomeDir: join(home, '.grok'), - agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS - } - - const cold = await installRemoteManagedAgentHooks(filesystem, home, options) - const warm = await installRemoteManagedAgentHooks(filesystem, home, options) - - expect(cold).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) - expect(cold.filter((result) => result.state === 'error')).toEqual([]) - expect(warm).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) - expect(warm.filter((result) => result.state === 'error')).toEqual([]) - const files = await listFiles(home) - expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([]) - const scripts = files.filter((path) => /\.(?:sh|cmd)$/.test(path)) - expect(scripts.length).toBeGreaterThanOrEqual(10) - if (process.platform !== 'win32') { - for (const script of scripts) { - expect((await stat(script)).mode & 0o777).toBe(0o755) - } - } - }) - - it('isolates a malformed config while installing the remaining agents', async () => { - const home = await createTempHome() - const claudeConfig = join(home, '.claude', 'settings.json') - await mkdir(join(home, '.claude'), { recursive: true }) - await writeFile(claudeConfig, '{"hooks": }', 'utf8') - - const results = await installRemoteManagedAgentHooks(createManagedHookLocalFilesystem(), home, { - grokHomeDir: join(home, '.grok'), - agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS - }) - - expect(results).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) - expect(results.find((result) => result.agent === 'claude')?.state).toBe('error') - expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed') - expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed') - expect(results.find((result) => result.agent === 'jcode')?.state).toBe('installed') - expect(await readFile(claudeConfig, 'utf8')).toBe('{"hooks": }') - }) -}) diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts deleted file mode 100644 index f6e55eab1def..000000000000 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeChildProcess from 'node:child_process' - -// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every -// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling -// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the -// first case silently flips to the fallback path. -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, execFile: vi.fn() } -}) - -const { execFile } = await import('node:child_process') -const execFileMock = vi.mocked(execFile) -const { execFile: actualExecFile } = - await vi.importActual('node:child_process') -const { installManagedHooks, resolveRelayGrokHome, resolveRelayKiroHome } = - await import('./managed-hook-runtime') - -type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void - -function stubProbeOutput(stdout: string): void { - execFileMock.mockImplementation(((...args: unknown[]) => { - ;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' }) - return undefined - }) as unknown as typeof execFile) -} - -function stubProbeFailure(error: Error): void { - execFileMock.mockImplementation(((...args: unknown[]) => { - ;(args.at(-1) as ExecFileCallback)(error) - return undefined - }) as unknown as typeof execFile) -} - -beforeEach(() => { - execFileMock.mockReset() - // Why: `installManagedHooks` proves a skipped probe through the login-shell run log, so the - // probe must really spawn unless a case above stubs it. The mock loses `promisify.custom`, - // so the real `(error, stdout, stderr)` callback is reshaped into the `{ stdout, stderr }` - // that `promisify(execFile)` resolves in production. - execFileMock.mockImplementation(((...args: unknown[]) => { - const callback = args.at(-1) as ExecFileCallback - return (actualExecFile as (...callArgs: unknown[]) => unknown)( - ...args.slice(0, -1), - (error: Error | null, stdout: string, stderr: string) => callback(error, { stdout, stderr }) - ) - }) as unknown as typeof execFile) -}) - -const tempHomes: string[] = [] -const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' -const SHELL_NAME = 'login-shell' -const SHELL_RUNS_NAME = 'login-shell-runs' - -async function createTempHome(): Promise { - const home = await mkdtemp(join(tempRoot, 'orca-managed-hook-runtime-')) - tempHomes.push(home) - return home -} - -/** Login shell that records each invocation, so a skipped GROK_HOME probe is observable. */ -async function stubLoginShell(home: string): Promise { - const shell = join(home, SHELL_NAME) - await writeFile( - shell, - `#!/bin/sh\necho ran >> "${join(home, SHELL_RUNS_NAME)}"\nexit 0\n`, - 'utf8' - ) - await chmod(shell, 0o755) - vi.stubEnv('HOME', home) - vi.stubEnv('SHELL', shell) -} - -afterEach(async () => { - vi.unstubAllEnvs() - await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) -}) - -describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { - it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('/srv/grok///\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') - - const [shell, args] = execFileMock.mock.calls[0] ?? [] - expect(shell).toBe('/bin/sh') - // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. - expect(args?.[0]).toBe('-c') - }) - - it('passes -lc to a login shell that supports it', async () => { - vi.stubEnv('SHELL', '/bin/zsh') - stubProbeOutput('/srv/grok\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') - - const [shell, args] = execFileMock.mock.calls[0] ?? [] - expect(shell).toBe('/bin/zsh') - expect(args?.[0]).toBe('-lc') - }) - - it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('../relative\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') - }) - - // Why: this is the branch that made the old test flaky — pin it so a probe failure is - // an asserted fallback rather than an invisible substitution for a real answer. - it('falls back when the probe fails or times out', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') - }) -}) - -describe.runIf(process.platform !== 'win32')('resolveRelayKiroHome', () => { - it('reads KIRO_HOME from the login shell', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('/srv/kiro/\n') - - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/srv/kiro') - expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('printenv KIRO_HOME') - }) - - it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('\n') - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') - - stubProbeFailure(new Error('spawn failed')) - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') - }) -}) - -describe.runIf(process.platform !== 'win32')('installManagedHooks', () => { - it.each([ - ['omitted', undefined], - ['empty', { agents: [] }] - ])( - 'writes nothing and runs no probe when the allowlist is %s (issue #11641)', - async (_label, options) => { - const home = await createTempHome() - await stubLoginShell(home) - - await expect(installManagedHooks(options)).resolves.toEqual({ installers: 0, errors: 0 }) - - // Why: no agent config home, no ~/.orca install lock, and no GROK_HOME login-shell probe. - expect(await readdir(home)).toEqual([SHELL_NAME]) - } - ) - - it('still rejects an aborted request rather than resolving an empty summary', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - installManagedHooks({ signal: controller.signal, agents: [] }) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('installs only the allowlisted agent, under the install lock', async () => { - const home = await createTempHome() - await stubLoginShell(home) - - await expect(installManagedHooks({ agents: ['claude'] })).resolves.toEqual({ - installers: 1, - errors: 0 - }) - - expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME]) - }) - - it('still probes the login shell when Grok is selected', async () => { - const home = await createTempHome() - await stubLoginShell(home) - - await expect(installManagedHooks({ agents: ['grok'] })).resolves.toEqual({ - installers: 1, - errors: 0 - }) - - expect((await readdir(home)).sort()).toEqual(['.grok', '.orca', SHELL_NAME, SHELL_RUNS_NAME]) - }) -}) diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts deleted file mode 100644 index e8a32187f8a0..000000000000 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { execFile } from 'node:child_process' -import { basename } from 'node:path' -import { homedir, userInfo } from 'node:os' -import { promisify } from 'node:util' -import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' -import type { AgentHookTarget } from '../../shared/agent-hook-types' -import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' -import { withManagedHookInstallLock } from './managed-hook-install-lock' -import { - readManagedHookHostIdentity, - scopeManagedHookHostIdentity -} from './managed-hook-owner-identity' - -const execFileAsync = promisify(execFile) -const AGENT_HOME_MAX_LENGTH = 4096 -const AGENT_HOME_PROBE_TIMEOUT_MS = 8_000 - -export type ManagedHookInstallSummary = { - installers: number - errors: number -} - -function defaultAgentHome(home: string, dirName: string): string { - return `${home.replace(/\/+$/, '') || home}/${dirName}` -} - -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -function normalizeAgentHome(candidate: string): string | null { - if ( - candidate.length === 0 || - candidate.length > AGENT_HOME_MAX_LENGTH || - candidate !== candidate.trim() || - !candidate.startsWith('/') || - candidate.includes('\\') || - hasControlCharacter(candidate) - ) { - return null - } - return candidate.replace(/\/+$/, '') || '/' -} - -function resolveLoginShell(): string { - const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' - if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { - return '/bin/sh' - } - return candidate -} - -async function resolveRelayAgentHome( - envName: 'GROK_HOME' | 'KIRO_HOME', - fallback: string, - signal?: AbortSignal -): Promise { - try { - const shell = resolveLoginShell() - const shellName = basename(shell) - const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' - // Why: agent PTYs start login shells, so read the same profile-derived - // home override without opening two additional SSH exec channels. - const { stdout } = await execFileAsync( - shell, - [mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`], - { encoding: 'utf8', timeout: AGENT_HOME_PROBE_TIMEOUT_MS, signal } - ) - return normalizeAgentHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback - } catch { - signal?.throwIfAborted() - return fallback - } -} - -export function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise { - return resolveRelayAgentHome('GROK_HOME', defaultAgentHome(home, '.grok'), signal) -} - -/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */ -export function resolveRelayKiroHome(home: string, signal?: AbortSignal): Promise { - return resolveRelayAgentHome('KIRO_HOME', defaultAgentHome(home, '.kiro'), signal) -} - -export async function installManagedHooks(options?: { - signal?: AbortSignal - hostKeyFingerprint?: string - agents?: readonly AgentHookTarget[] - claudeVersion?: string -}): Promise { - options?.signal?.throwIfAborted() - // Why: empty/omitted allowlist fails closed before any home/host probes. - const agents = options?.agents ?? [] - if (agents.length === 0) { - return { installers: 0, errors: 0 } - } - const home = homedir() - // Why parallel: each probe may wait out its own login-shell timeout. - const [grokHomeDir, kiroHomeDir] = await Promise.all([ - agents.includes('grok') ? resolveRelayGrokHome(home, options?.signal) : undefined, - agents.includes('kiro') ? resolveRelayKiroHome(home, options?.signal) : undefined - ]) - options?.signal?.throwIfAborted() - const hostIdentity = scopeManagedHookHostIdentity( - await readManagedHookHostIdentity(), - options?.hostKeyFingerprint - ) - return await withManagedHookInstallLock( - home, - options?.signal, - async () => { - const results = await installRemoteManagedAgentHooks( - createManagedHookLocalFilesystem(), - home, - { - grokHomeDir, - kiroHomeDir, - signal: options?.signal, - agents, - ...(options?.claudeVersion ? { claudeVersion: options.claudeVersion } : {}) - } - ) - return { - installers: results.length, - errors: results.filter((result) => result.state === 'error').length - } - }, - hostIdentity - ) -} diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 4b59f7b53ed9..4cc61d2988f0 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -12,17 +12,40 @@ import { const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/ const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*' +function assertManagedScriptBaseName(scriptBaseName: string): void { + if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { + throw new Error(`Invalid managed script base name: ${scriptBaseName}`) + } +} + +function buildPosixBranch( + scriptBaseName: string, + missingScriptFallback: string, + options: { requireHome?: boolean } = {} +): string { + const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"` + const homeGuard = options.requireHome ? '[ -n "${HOME-}" ] && ' : '' + const parentCapture = + scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' + ? CLAUDE_HOOK_PARENT_CAPTURE + : '' + return `if ${homeGuard}[ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${parentCapture}/bin/sh ${posixScript}; else ${missingScriptFallback}; fi` +} + +/** The POSIX branch of wrapRuntimeHomeHookCommand alone, for hosts known to be POSIX. */ +export function wrapRuntimeHomePosixHookCommand(scriptBaseName: string): string { + assertManagedScriptBaseName(scriptBaseName) + return buildPosixBranch(scriptBaseName, POSIX_HOOK_STDIN_DRAIN_COMMAND, { requireHome: true }) +} + export function wrapRuntimeHomeHookCommand( scriptBaseName: string, options: { neutralJsonWhenMissing?: boolean } = {} ): string { - if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { - throw new Error(`Invalid managed script base name: ${scriptBaseName}`) - } + assertManagedScriptBaseName(scriptBaseName) // Why: default-form every var — a static hook precheck (Grok) rejects the whole command on a bare // reference it cannot resolve, even in a branch that platform never takes. const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"` - const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"` const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND const neutralJson = options.neutralJsonWhenMissing ? `printf '{}\\n'` : '' // Why two forms: the missing-script fallback owns stdin, so it follows the rule of the host @@ -47,7 +70,7 @@ export function wrapRuntimeHomeHookCommand( const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi` - const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' ? CLAUDE_HOOK_PARENT_CAPTURE : ''}/bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi` + const posixBranch = buildPosixBranch(scriptBaseName, posixMissingScriptFallback) // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/agent-hooks/server-nested-agent-model.test.ts b/src/main/agent-hooks/server-nested-agent-model.test.ts new file mode 100644 index 000000000000..3fbdb435dcfa --- /dev/null +++ b/src/main/agent-hooks/server-nested-agent-model.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ + track: vi.fn() +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +type StatusPayload = { + state: 'working' | 'done' + prompt: string + agentType: string + model?: string +} + +function ingest(server: AgentHookServer, at: number, payload: StatusPayload): void { + vi.setSystemTime(at) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', hasExplicitPrompt: true, payload }, + 'conn-1' + ) +} + +beforeEach(() => { + _internals.resetCachesForTests() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('AgentHookServer nested agent model fence', () => { + // Why: `claude` running `codex exec` fires codex hooks on the inherited pane key. + it('keeps the parent model when a nested hook reports its own model', () => { + const server = new AgentHookServer() + ingest(server, 1_000, { + state: 'working', + prompt: 'parent claude', + agentType: 'claude', + model: 'claude-fable-5' + }) + ingest(server, 1_100, { + state: 'working', + prompt: 'nested codex', + agentType: 'codex', + model: 'gpt-6-astra' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, agentType: 'claude', model: 'claude-fable-5' }) + ]) + }) + + it('drops the nested model when the fenced parent never reported one', () => { + const server = new AgentHookServer() + ingest(server, 1_000, { state: 'working', prompt: 'parent claude', agentType: 'claude' }) + ingest(server, 1_100, { + state: 'working', + prompt: 'nested codex', + agentType: 'codex', + model: 'gpt-6-astra' + }) + + const [row] = server.getStatusSnapshot() + expect(row).toMatchObject({ agentType: 'claude' }) + expect(row?.model).toBeUndefined() + }) + + it("keeps a codex parent's model through a nested model-less hook", () => { + const server = new AgentHookServer() + ingest(server, 1_000, { + state: 'working', + prompt: 'parent codex', + agentType: 'codex', + model: 'gpt-6-astra' + }) + ingest(server, 1_100, { state: 'working', prompt: 'nested claude', agentType: 'claude' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ agentType: 'codex', model: 'gpt-6-astra' }) + ]) + }) + + it('still updates the label on a genuine model switch by the same agent', () => { + const server = new AgentHookServer() + ingest(server, 1_000, { + state: 'working', + prompt: 'first', + agentType: 'claude', + model: 'claude-fable-5' + }) + ingest(server, 1_100, { + state: 'working', + prompt: 'second', + agentType: 'claude', + model: 'claude-opus-5-5' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ agentType: 'claude', model: 'claude-opus-5-5' }) + ]) + }) + + it('lets a new agent claim the pane with its model once the parent turn is done', () => { + const server = new AgentHookServer() + ingest(server, 1_000, { + state: 'done', + prompt: 'parent claude', + agentType: 'claude', + model: 'claude-fable-5' + }) + ingest(server, 1_100, { + state: 'working', + prompt: 'codex turn', + agentType: 'codex', + model: 'gpt-6-astra' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ agentType: 'codex', model: 'gpt-6-astra' }) + ]) + }) + + // Why: readers retain sparse model labels, so the host must never publish a child's label on the parent. + it('never pushes a nested codex model to the renderer from real hook bodies', async () => { + vi.useRealTimers() + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const pushed: { agentType?: string; model?: string }[] = [] + server.setListener((event) => pushed.push(event.payload)) + try { + const steps: [string, Record][] = [ + ['/hook/claude', { hook_event_name: 'UserPromptSubmit', session_id: 'c-1', prompt: 'p' }], + [ + '/hook/codex', + { + hook_event_name: 'SessionStart', + session_id: 'x-1', + model: 'gpt-6-astra', + source: 'startup' + } + ], + [ + '/hook/codex', + { + hook_event_name: 'UserPromptSubmit', + session_id: 'x-1', + prompt: 'n', + model: 'gpt-6-astra' + } + ], + [ + '/hook/codex', + { + hook_event_name: 'PreToolUse', + session_id: 'x-1', + model: 'gpt-6-astra', + tool_name: 'shell' + } + ], + [ + '/hook/codex', + { + hook_event_name: 'Stop', + session_id: 'x-1', + model: 'gpt-6-astra', + stop_hook_active: false + } + ], + ['/hook/claude', { hook_event_name: 'Stop', session_id: 'c-1' }] + ] + for (const [path, body] of steps) { + expect((await postHookEvent(server, buildBody(body), path)).status).toBe(204) + } + const [row] = server.getStatusSnapshot() + expect(row).toMatchObject({ agentType: 'claude', state: 'done' }) + expect(row?.model).toBeUndefined() + } finally { + server.stop() + } + + expect(pushed.length).toBeGreaterThanOrEqual(4) + expect(pushed.every((payload) => payload.agentType === 'claude')).toBe(true) + expect(pushed.map((payload) => payload.model)).not.toContain('gpt-6-astra') + }) +}) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 9b4c61b69473..cf808509c5df 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -181,8 +181,19 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.commitStatusRowMutation(rowBefore, previous) return previous } - const identityResolvedPayload = - identity.agentType === rootContextPreservingPayload.payload.agentType + const { model: _nestedModel, ...modelFencedPayload } = rootContextPreservingPayload.payload + const parentModel = previous?.payload.model + const identityResolvedPayload = identity.inheritedFromActivePane + ? { + ...rootContextPreservingPayload, + // Why: a nested CLI's model must stay with the child whose identity was fenced out. + payload: { + ...modelFencedPayload, + agentType: identity.agentType, + ...(parentModel ? { model: parentModel } : {}) + } + } + : identity.agentType === rootContextPreservingPayload.payload.agentType ? rootContextPreservingPayload : { ...rootContextPreservingPayload, diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index 365e4d5fcd85..9df5013f525c 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { WslHookRelayManager } from './wsl-hook-relay-manager' -import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem.test-fixture' import { codexHookService } from '../codex/hook-service' const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl') diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index e921445ef5c2..0e25f326b142 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -1,10 +1,7 @@ import '../runtime/rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it } from 'vitest' -import { AiVaultHandler } from '../../relay/ai-vault-handler' -import type { RelayDispatcher } from '../../wsl-guest/dispatcher' import { createSessionSearchClient } from '../../shared/ai-vault-search-client' import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' -import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault' @@ -28,22 +25,6 @@ afterEach(() => { resetSessionSearchScopeCatalogForTests() }) -function relayHandler(): (params: Record) => Promise { - const handlers = new Map) => Promise>() - const dispatcher = { - onRequest: (method: string, handler: (params: Record) => Promise) => { - handlers.set(method, handler) - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls `onRequest`, and the test asserts the registration it makes. - new AiVaultHandler(dispatcher as unknown as RelayDispatcher) - const search = handlers.get('aiVault.searchSessions') - if (!search) { - throw new Error('relay registered no session search handler') - } - return search -} - describe('every search entry point carries the scope identity through', () => { it('resolves over the in-process IPC entry point', async () => { const service = fakeSearchService() @@ -76,40 +57,6 @@ describe('every search entry point carries the scope identity through', () => { }) }) - it('resolves over the relay entry point', async () => { - const service = fakeSearchService() - setSessionSearchService(service) - installSessionSearchScopeCatalogSource(() => CATALOG) - await relayHandler()({ - query: 'needle', - supportedAgents: [...AI_VAULT_AGENTS], - supportsQoderHistory: true, - supportsJcodeHistory: true, - within: WITHIN - }) - expect(service.search).toHaveBeenCalledWith(expect.anything(), { - kind: 'resolved', - paths: ['/work/app'] - }) - }) - - it('hands the relay’s own verdict down, that host carrying no repo catalog', async () => { - const service = fakeSearchService() - setSessionSearchService(service) - await relayHandler()({ - query: 'needle', - supportedAgents: [...AI_VAULT_AGENTS], - supportsQoderHistory: true, - supportsJcodeHistory: true, - within: WITHIN - }) - expect(service.search).toHaveBeenCalledWith( - { query: 'needle', limit: 20 }, - { - kind: 'unknown' - } - ) - }) }) describe('the shared remote client', () => { diff --git a/src/main/ai-vault/antigravity-ide-history.test.ts b/src/main/ai-vault/antigravity-ide-history.test.ts index 02787c85e83f..2d40ef400d8f 100644 --- a/src/main/ai-vault/antigravity-ide-history.test.ts +++ b/src/main/ai-vault/antigravity-ide-history.test.ts @@ -2,13 +2,10 @@ import { runProcess } from '@orca/process-host' import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, writeAntigravityTranscript } from './session-scanner-test-fixtures' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { dedupeScannedSessions } from './session-root-dedup' +import { jsonLines } from './remote-session-scanner-test-fixtures' import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin' import { readLocalAntigravityHistory } from './session-scanner-antigravity-history' import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata' @@ -190,39 +187,4 @@ describe('Antigravity IDE history discovery', () => { expect(await readLocalAntigravityHistory(file)).toBeNull() }) - it('reads only the execution host and never collapses equal origin IDs from different remote hosts', async () => { - const provider = new MemoryRemoteProvider() - const remoteHome = '/remote/home' - for (const origin of origins) { - const logs = `${remoteHome}/.gemini/${origin}/brain/${conversationId}/.system_generated/logs` - provider.addFile(`${logs}/transcript_full.jsonl`, jsonLines(records(origin)), 10) - provider.addFile(`${logs}/transcript.jsonl`, jsonLines(records(origin)), 11) - } - const read = vi.spyOn(provider, 'readFile') - const options = { - provider, - remoteHome, - hostPlatform: getRemoteHostPlatform('linux-x64'), - includeAntigravityIdeSessions: true - } - const first = await scanRemoteAiVaultSessions({ ...options, executionHostId: 'ssh:first-host' }) - const second = await scanRemoteAiVaultSessions({ - ...options, - executionHostId: 'ssh:second-host' - }) - expect(first.sessions).toHaveLength(3) - expect(second.sessions).toHaveLength(3) - expect(dedupeScannedSessions([...first.sessions, ...second.sessions])).toHaveLength(6) - expect(read.mock.calls.every(([path]) => path.startsWith(remoteHome))).toBe(true) - expect(provider.readDirPaths.every((path) => path.startsWith(remoteHome))).toBe(true) - expect( - first.sessions.every((session) => session.filePath.endsWith('transcript_full.jsonl')) - ).toBe(true) - const legacy = await scanRemoteAiVaultSessions({ - ...options, - executionHostId: 'ssh:first-host', - includeAntigravityIdeSessions: false - }) - expect(legacy.sessions).toHaveLength(1) - }) }) diff --git a/src/main/ai-vault/antigravity-index-file-admission.test.ts b/src/main/ai-vault/antigravity-index-file-admission.test.ts index 3675b1a28d35..95b3a9743cdd 100644 --- a/src/main/ai-vault/antigravity-index-file-admission.test.ts +++ b/src/main/ai-vault/antigravity-index-file-admission.test.ts @@ -1,6 +1,6 @@ import type * as NodeFsPromises from 'node:fs/promises' import { constants } from 'node:fs' -import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -23,7 +23,6 @@ vi.mock('node:fs/promises', async (original) => { }) import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' import { readLocalAntigravityHistory } from './session-scanner-antigravity-history' -import { readRelayTranscriptBytes } from '../../relay/ai-vault-transcript-stream' const roots: string[] = [] async function filePath() { @@ -43,25 +42,6 @@ afterEach(async () => { }) describe('bounded regular metadata reads', () => { - it.skipIf(process.platform === 'win32')( - 'rejects real FIFO paths and symlinks without waiting for a writer', - async () => { - const file = await filePath() - await fifo(file) - const alias = `${file}.link` - await symlink(file, alias) - for (const path of [file, alias]) { - expect(await readLocalAntigravityHistory(path)).toBeNull() - await expect(readNodeFileWithinLimit(path, 64, { regularFileOnly: true })).rejects.toThrow( - 'regular file' - ) - await expect( - readRelayTranscriptBytes(path, undefined, { regularFileOnly: true, maxBytes: 64 }).next() - ).rejects.toThrow('regular file') - } - }, - 2000 - ) it.skipIf(process.platform === 'win32')( 'rejects a regular path replaced by a FIFO between stat and open', diff --git a/src/main/ai-vault/antigravity-index-reader.test.ts b/src/main/ai-vault/antigravity-index-reader.test.ts deleted file mode 100644 index 603c3401d13a..000000000000 --- a/src/main/ai-vault/antigravity-index-reader.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { readRemoteAntigravityIndex } from './antigravity-index-reader' -import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' -import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' -import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata' - -function provider(): RemoteSessionFilesystemProvider { - return { - readDir: async () => [], - stat: async () => ({ type: 'file', size: 2, mtime: 0 }), - readFile: async () => ({ content: '{}', isBinary: false }) - } -} - -describe('execution-host Antigravity metadata reads', () => { - it('forwards regular-file admission and byte limits through the concurrency wrapper', async () => { - const host = provider() - const read = vi.fn(async function* () { - yield Buffer.from('{}') - }) - host.readTranscriptBytes = read - expect( - await readRemoteAntigravityIndex( - limitRemoteScanFilesystemConcurrency(host), - '/host/projects.json' - ) - ).toBe('{}') - expect(read).toHaveBeenCalledWith('/host/projects.json', undefined, { - regularFileOnly: true, - maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES - }) - }) - - it('preserves the existing bounded read-file options on legacy providers', async () => { - const host = provider() - const read = vi.spyOn(host, 'readFile') - expect( - await readRemoteAntigravityIndex( - limitRemoteScanFilesystemConcurrency(host), - '/host/projects.json' - ) - ).toBe('{}') - expect(read).toHaveBeenCalledWith('/host/projects.json', { - maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES - }) - }) - - it('does not read directories or symlinks from legacy providers', async () => { - for (const type of ['directory', 'symlink'] as const) { - const host = provider() - host.stat = async () => ({ type, size: 0, mtime: 0 }) - const read = vi.spyOn(host, 'readFile') - expect(await readRemoteAntigravityIndex(host, '/host/projects.json')).toBeNull() - expect(read).not.toHaveBeenCalled() - } - }) - - it('stops waiting for a legacy request that cannot cancel on the wire', async () => { - const host = provider() - host.readFile = () => new Promise(() => {}) - const controller = new AbortController() - const read = readRemoteAntigravityIndex(host, '/host/projects.json', controller.signal) - await Promise.resolve() - controller.abort() - await expect(read).rejects.toMatchObject({ name: 'AbortError' }) - }) -}) diff --git a/src/main/ai-vault/antigravity-index-reader.ts b/src/main/ai-vault/antigravity-index-reader.ts deleted file mode 100644 index a14d4cde5036..000000000000 --- a/src/main/ai-vault/antigravity-index-reader.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - ANTIGRAVITY_INDEX_MAX_BYTES, - readBoundedAntigravityIndex -} from './session-scanner-antigravity-metadata' -import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' -import { - abandonRemoteSessionScanOnCancel, - throwIfAiVaultScanCancelled -} from './ai-vault-scan-cancellation' - -export async function readRemoteAntigravityIndex( - provider: RemoteSessionFilesystemProvider, - path: string, - signal?: AbortSignal -): Promise { - try { - throwIfAiVaultScanCancelled(signal) - if (provider.readTranscriptBytes) { - return await abandonRemoteSessionScanOnCancel( - readBoundedAntigravityIndex( - provider.readTranscriptBytes(path, signal, { - regularFileOnly: true, - maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES - }) - ), - signal - ) - } - const stat = await abandonRemoteSessionScanOnCancel(provider.stat(path), signal) - if (stat.type !== 'file' || stat.size > ANTIGRAVITY_INDEX_MAX_BYTES) { - return null - } - const read = await abandonRemoteSessionScanOnCancel( - provider.readFile(path, { maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES }), - signal - ) - throwIfAiVaultScanCancelled(signal) - return read.isBinary || Buffer.byteLength(read.content) > ANTIGRAVITY_INDEX_MAX_BYTES - ? null - : read.content - } catch (error) { - throwIfAiVaultScanCancelled(signal) - if (error instanceof Error && error.name === 'AbortError') { - throw error - } - return null - } -} diff --git a/src/main/ai-vault/openclaw-session-layout.test.ts b/src/main/ai-vault/openclaw-session-layout.test.ts index 842ca15a4e06..12c21b47c1b3 100644 --- a/src/main/ai-vault/openclaw-session-layout.test.ts +++ b/src/main/ai-vault/openclaw-session-layout.test.ts @@ -1,11 +1,7 @@ -import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' -import { remoteSessionSources } from './remote-session-scanner-sources' -import type { RemoteScannerContext } from './remote-session-scanner-types' import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources' import { walkSessionFiles } from './session-scanner-discovery' @@ -49,44 +45,4 @@ describe('OpenClaw session discovery', () => { expect(relativeToAgents(files)).toEqual(EXPECTED) }) - it('never lists an embedded agent home over a remote filesystem', async () => { - const readDirs: string[] = [] - const provider: RemoteScannerContext['provider'] = { - readDir: async (dirPath) => { - readDirs.push(dirPath) - const entries = await readdir(dirPath, { withFileTypes: true }) - return entries.map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - }, - stat: async (path) => { - const info = await stat(path) - return { size: info.size, type: 'file', mtime: info.mtimeMs } - }, - readFile: async () => ({ content: '', isBinary: false }) - } - const hostPlatform = getRemoteHostPlatform( - process.platform === 'win32' ? 'win32-x64' : 'linux-x64' - ) - const source = remoteSessionSources(home, hostPlatform).find( - (candidate) => candidate.agent === 'openclaw' - ) - - const context: RemoteScannerContext = { - provider, - executionHostId: 'ssh:host', - hostPlatform, - titleCaches: new Map(), - antigravityWorkspaceResolver: { enrich: async (session) => session } - } - expect(source).toBeDefined() - const candidates = source - ? await discoverRemoteSourceCandidates({ source, context, issues: [] }) - : [] - - expect(relativeToAgents(candidates.map(({ file }) => file.path))).toEqual(EXPECTED) - expect(readDirs.some((dir) => dir.includes('codex-home'))).toBe(false) - }) }) diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index 8ba01bf46176..e2f8781202e8 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -87,10 +87,7 @@ describe('WSL SQLite runtime preparation', () => { args: ['-e', expect.stringMatching(/require\('node:sqlite'\).*backup/)] }) ) - expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual([ - 'linux-x64', - 'linux-arm64' - ]) + expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual(['wsl']) expect(mocks.download).not.toHaveBeenCalled() }) @@ -263,7 +260,7 @@ describe('WSL SQLite runtime preparation', () => { mocks.run.mockResolvedValueOnce(success('')) await prepare([home]) await vi.waitFor(async () => - expect((await prepare([home]))[0]?.error).toContain('No OpenCode database') + expect((await prepare([home]))[0]?.error).toContain('No OpenCode or ZCode database') ) expect(mocks.run).toHaveBeenCalledOnce() expect(mocks.download).not.toHaveBeenCalled() @@ -274,16 +271,19 @@ describe('WSL SQLite runtime preparation', () => { expect(mocks.running).toHaveBeenCalledWith(expect.any(Array), { requireConfirmed: true }) }) - it.skipIf(process.platform === 'win32')( - 'keeps the discovery default visible when guest overrides point elsewhere', - async () => { + it.skipIf(process.platform === 'win32').each(['opencode', 'zcode'] as const)( + 'prepares a %s-only distro despite unrelated OpenCode overrides', + async (agent) => { await prepared() const script = mocks.run.mock.calls[0]?.[0].script const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-presence-')) try { - const data = join(directory, '.local', 'share', 'opencode') + const data = + agent === 'zcode' + ? join(directory, '.zcode', 'cli', 'db') + : join(directory, '.local', 'share', 'opencode') await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') + await writeFile(join(data, agent === 'zcode' ? 'db.sqlite' : 'opencode.db'), '') const result = await runProcess({ program: '/bin/sh', args: ['-c', script], diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index 1f242772acd3..c039bae0f897 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -102,9 +102,7 @@ async function prepare(distro: string): Promise { return result.stdout.trim() } const app = getAppEnvironment() - // The reader is plain JavaScript; either packaged Linux architecture is usable. - const reader = (['linux-x64', 'linux-arm64'] as const) - .flatMap((platform) => relayBundleCandidates(platform, app.getAppPath())) + const reader = relayBundleCandidates('wsl', app.getAppPath()) .map((directory) => join(directory, RELAY_OPENCODE_SQLITE_READER_FILENAME)) .find(existsSync) if (!reader) { @@ -113,6 +111,7 @@ async function prepare(distro: string): Promise { const hasDatabase = await run({ script: [ 'data="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"', + 'if [ -f "$HOME/.zcode/cli/db/db.sqlite" ]; then printf present; exit 0; fi', // WSL discovery still enumerates the default data root independently of guest overrides. 'for db in "$HOME/.local/share/opencode"/opencode.db "$HOME/.local/share/opencode"/opencode-*.db; do if [ -f "$db" ]; then printf present; exit 0; fi; done', 'case "${OPENCODE_DB-}" in', @@ -125,7 +124,7 @@ async function prepare(distro: string): Promise { loginPath: 'none' }) if (hasDatabase !== 'present') { - return { distro, error: 'No OpenCode database is present in this WSL distro.' } + return { distro, error: 'No OpenCode or ZCode database is present in this WSL distro.' } } const readerPath = await run({ program: 'wslpath', diff --git a/src/main/ai-vault/remote-session-document-parsers.ts b/src/main/ai-vault/remote-session-document-parsers.ts deleted file mode 100644 index 1dcd78ba08bf..000000000000 --- a/src/main/ai-vault/remote-session-document-parsers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AiVaultAgent } from '../../shared/ai-vault-types' -import { parseDevinSessionDocument } from './session-scanner-devin-parser' -import { parseHermesSessionDocument } from './session-scanner-hermes-parser' -import { - parseGeminiSessionDocument, - parseGeminiJsonlSessionLines -} from './session-scanner-gemini-parsers' -import type { RemoteSessionSource } from './remote-session-scanner-types' - -export function remoteSessionDocumentParsers( - agent: AiVaultAgent -): Pick { - const parse = - agent === 'hermes' - ? parseHermesSessionDocument - : agent === 'devin' - ? parseDevinSessionDocument - : agent === 'gemini' - ? parseGeminiSessionDocument - : null - if (!parse) { - return {} - } - return { - parseDocument: (file, bytes, context) => - parse( - file, - bytes, - context.hostPlatform.os, - { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }, - context.signal - ), - ...(agent === 'gemini' - ? { - parseLines: (file, lines, context) => - parseGeminiJsonlSessionLines({ - file, - lines, - platform: context.hostPlatform.os, - options: { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - } - }) - } - : {}) - } -} diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts deleted file mode 100644 index e6bac42fd7e2..000000000000 --- a/src/main/ai-vault/remote-session-large-transcripts.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { describe, it, expect } from 'vitest' -import { createRelayAiVaultFilesystemProvider } from '../../relay/ai-vault-service-filesystem' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' - -const platform = getRemoteHostPlatform( - process.platform === 'win32' - ? 'win32-x64' - : process.platform === 'darwin' - ? 'darwin-arm64' - : 'linux-x64' -) -const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).join('\n')}\n` -const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000) - -describe('large remote history through real relay filesystem', () => { - it('reports an oversized record without losing healthy sessions or publishing a partial session', async () => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-record-limit-')) - try { - const directory = join(home, '.codex', 'sessions') - await mkdir(directory, { recursive: true }) - const metadata = (id: string) => - jsonl([{ type: 'session_meta', payload: { id, cwd: '/repo' } }]) - const badPath = join(directory, 'bad.jsonl') - await writeFile(badPath, metadata('bad') + 'x'.repeat(11 * 1024 * 1024)) - await writeFile(join(directory, 'good.jsonl'), metadata('good')) - const result = await scanRemoteAiVaultSessions({ - provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }), - executionHostId: 'ssh:record-limit', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(result.sessions.map((session) => session.sessionId)).toEqual(['good']) - expect(result.issues).toEqual([ - expect.objectContaining({ - path: badPath.replace(/\\/g, '/'), - message: 'Session transcript record exceeds 10485760 byte limit' - }) - ]) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) - - it('lists a large Codex rollout with middle messages and usage intact', async () => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-')) - try { - const path = join(home, '.codex', 'sessions', 'large.jsonl') - await mkdir(dirname(path), { recursive: true }) - await writeFile( - path, - jsonl([ - { - type: 'session_meta', - timestamp: '2026-09-13T01:00:00Z', - payload: { id: 'large', cwd: '/repo' } - }, - { - type: 'response_item', - timestamp: '2026-09-13T01:00:01Z', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: 'Keep my history' }] - } - } - ]) + - filler.slice(0, filler.length / 2) + - jsonl([ - { - type: 'response_item', - timestamp: '2026-09-13T01:02:00Z', - payload: { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Middle answer' }] - } - }, - { - type: 'event_msg', - timestamp: '2026-09-13T01:03:00Z', - payload: { - type: 'token_count', - info: { - total_token_usage: { input_tokens: 123, output_tokens: 45, total_tokens: 168 } - } - } - } - ]) + - filler.slice(filler.length / 2) - ) - const result = await scanRemoteAiVaultSessions({ - provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }), - executionHostId: 'ssh:synthetic-17744', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - sessionId: 'large', - messageCount: 2, - totalTokens: 168, - title: 'Keep my history' - }) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) - - it.each(['hermes', 'devin', 'gemini', 'cline'] as const)( - 'lists large %s documents with every message counted', - async (agent) => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-document-')) - try { - const messages = Array.from({ length: 11000 }, () => ({ - role: 'assistant', - content: 'x'.repeat(1024) - })) - messages.splice(5000, 0, { role: 'user', content: 'A middle user turn' }) - let path: string, record: unknown - if (agent === 'hermes') { - path = join(home, '.hermes', 'sessions', 'large.json') - record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages } - } else if (agent === 'devin') { - path = - platform.os === 'win32' - ? join(home, 'AppData', 'Roaming', 'devin', 'cli', 'transcripts', 'large.json') - : join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json') - record = { - session_id: 'large', - working_directory: '/repo', - steps: messages.map((message) => ({ - ...message, - metadata: { - is_user_input: message.role === 'user', - metrics: { input_tokens: 2, output_tokens: 1 } - } - })) - } - } else if (agent === 'gemini') { - path = join(home, '.gemini', 'tmp', 'large.json') - record = { - sessionId: 'large', - messages: messages.map((message) => ({ - type: message.role === 'assistant' ? 'gemini' : 'user', - content: message.content - })) - } - } else { - path = join(home, '.cline', 'data', 'sessions', 'large', 'large.json') - record = { session_id: 'large', cwd: '/repo' } - await mkdir(dirname(path), { recursive: true }) - await writeFile(path.replace('.json', '.messages.json'), JSON.stringify({ messages })) - } - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(record)) - const result = await scanRemoteAiVaultSessions({ - provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }), - executionHostId: `ssh:large-${agent}`, - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ agent, sessionId: 'large', messageCount: 11001 }) - if (agent === 'devin') { - expect(result.sessions[0].totalTokens).toBe(33003) - } - } finally { - await rm(home, { recursive: true, force: true }) - } - } - ) - it('keeps normal-size reads on their existing path and supports providers without streaming', async () => { - const home = await mkdtemp(join(tmpdir(), 'orca-history-legacy-')) - try { - const directory = join(home, '.codex', 'sessions') - await mkdir(directory, { recursive: true }) - const content = jsonl([{ type: 'session_meta', payload: { id: 'small', cwd: '/repo' } }]) - await writeFile(join(directory, 'small.jsonl'), content) - const provider = createRelayAiVaultFilesystemProvider({ - homeDirectory: home, - environment: {} - }) - provider.readTranscriptBytes = () => { - throw new Error('Small file must keep its existing read path') - } - const small = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:small-original', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(small.issues).toEqual([]) - expect(small.sessions.map((session) => session.sessionId)).toEqual(['small']) - await writeFile(join(directory, 'large.jsonl'), content + filler) - const legacy = { readDir: provider.readDir, readFile: provider.readFile, stat: provider.stat } - const fallback = await scanRemoteAiVaultSessions({ - provider: legacy, - executionHostId: 'ssh:legacy-original', - remoteHome: home, - hostPlatform: platform, - unlimited: true - }) - expect(fallback.sessions.map((session) => session.sessionId)).toEqual(['small']) - expect( - fallback.issues.some( - (issue) => issue.path.endsWith('large.jsonl') && issue.message.includes('10MB limit') - ) - ).toBe(true) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/ai-vault/remote-session-parse-cache.test.ts b/src/main/ai-vault/remote-session-parse-cache.test.ts deleted file mode 100644 index 446563c0652e..000000000000 --- a/src/main/ai-vault/remote-session-parse-cache.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import type { FileReadResult } from '../providers/types' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' - -/** - * Counts whole-transcript reads, which is the cost #13753 is about. Codex's - * per-scan `session_index.jsonl` title lookup is one small file and is not part - * of the corpus term, so it is excluded rather than asserted on. - */ -class CountingRemoteProvider extends MemoryRemoteProvider { - readonly readFilePaths: string[] = [] - - override async readFile(filePath: string): Promise { - if (filePath.includes('/sessions/')) { - this.readFilePaths.push(filePath) - } - return await super.readFile(filePath) - } -} - -function transcript(sessionId: string, title: string, timestamp: string): string { - return jsonLines([ - { - timestamp, - type: 'session_meta', - payload: { id: sessionId, cwd: '/home/ada/repo' } - }, - { - timestamp, - type: 'response_item', - payload: { type: 'message', role: 'user', content: [{ type: 'text', text: title }] } - } - ]) -} - -function scan(provider: CountingRemoteProvider): ReturnType { - return scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) -} - -describe('remote AI Vault transcript re-reads', () => { - beforeEach(() => { - resetRemoteSessionParseCacheForTests() - }) - - it('does not re-read an unchanged corpus on the next scan', async () => { - const provider = new CountingRemoteProvider() - for (const day of ['07/07', '07/25', '08/10']) { - provider.addFile( - `/home/ada/.codex/sessions/2026/${day}/rollout-${day.replace('/', '')}.jsonl`, - transcript( - `session-${day.replace('/', '')}`, - `Work from ${day}`, - '2026-07-07T01:00:00.000Z' - ), - 1_000 - ) - } - - const first = await scan(provider) - expect(first.sessions).toHaveLength(3) - expect(provider.readFilePaths).toHaveLength(3) - - provider.readFilePaths.length = 0 - const second = await scan(provider) - - // Historical transcripts are immutable; a second pass must cost zero reads. - expect(provider.readFilePaths).toEqual([]) - expect(second.sessions.map((session) => session.title)).toEqual( - first.sessions.map((session) => session.title) - ) - }) - - it('re-reads a transcript that actually changed', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-live.jsonl' - provider.addFile( - path, - transcript('live-session', 'First prompt', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - - await scan(provider) - provider.readFilePaths.length = 0 - - provider.addFile( - path, - transcript('live-session', 'Second prompt', '2026-08-31T02:00:00.000Z'), - 2_000 - ) - const result = await scan(provider) - - expect(provider.readFilePaths).toEqual([path]) - expect(result.sessions[0]?.title).toBe('Second prompt') - }) - - it('re-reads when only the size changed under an unchanged mtime', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-grown.jsonl' - provider.addFile(path, transcript('grown-session', 'Short', '2026-08-31T01:00:00.000Z'), 1_000) - - await scan(provider) - provider.readFilePaths.length = 0 - - provider.addFile( - path, - transcript( - 'grown-session', - 'A much longer first prompt than before', - '2026-08-31T01:00:00.000Z' - ), - 1_000 - ) - const result = await scan(provider) - - expect(provider.readFilePaths).toEqual([path]) - expect(result.sessions[0]?.title).toBe('A much longer first prompt than before') - }) - - // Codex names threads in $CODEX_HOME/session_index.jsonl asynchronously, after - // the rollout's last append — so the transcript's mtime+size never changes to - // signal it. That file sits outside `sessions/`, hence outside the read count. - it('picks up a session_index title written after the transcript was cached', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-named-later.jsonl' - provider.addFile( - path, - transcript('named-later-session', 'First prompt', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - provider.addFile( - '/home/ada/.codex/session_index.jsonl', - jsonLines([{ id: 'some-other-session', thread_name: 'Unrelated thread' }]), - 1_000 - ) - - const first = await scan(provider) - expect(first.sessions[0]?.title).toBe('First prompt') - - provider.readFilePaths.length = 0 - provider.addFile( - '/home/ada/.codex/session_index.jsonl', - jsonLines([ - { id: 'some-other-session', thread_name: 'Unrelated thread' }, - { id: 'named-later-session', thread_name: 'Named by Codex after the fact' } - ]), - 2_000 - ) - const second = await scan(provider) - - expect(second.sessions[0]?.title).toBe('Named by Codex after the fact') - // The #13753 win is preserved: the index is read, the transcript is not. - expect(provider.readFilePaths).toEqual([]) - }) - - it('does not serve a cached parse to a different execution host', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-host.jsonl' - provider.addFile( - path, - transcript('host-session', 'Host scoped', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - - await scan(provider) - provider.readFilePaths.length = 0 - - const other = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:other-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(provider.readFilePaths).toEqual([path]) - expect(other.sessions[0]?.executionHostId).toBe('ssh:other-box') - }) - - it('does not cache a read that failed', async () => { - const provider = new CountingRemoteProvider() - const path = '/home/ada/.codex/sessions/2026/08/31/rollout-flaky.jsonl' - provider.addFile( - path, - transcript('flaky-session', 'Recovered', '2026-08-31T01:00:00.000Z'), - 1_000 - ) - - let failNextRead = true - const originalReadFile = provider.readFile.bind(provider) - provider.readFile = async (filePath: string): Promise => { - if (failNextRead && filePath === path) { - failNextRead = false - provider.readFilePaths.push(filePath) - throw new Error('EIO: transient relay read failure') - } - return await originalReadFile(filePath) - } - - const failed = await scan(provider) - expect(failed.sessions).toEqual([]) - expect(failed.issues).toHaveLength(1) - - provider.readFilePaths.length = 0 - const recovered = await scan(provider) - - expect(provider.readFilePaths).toEqual([path]) - expect(recovered.sessions[0]?.title).toBe('Recovered') - }) -}) diff --git a/src/main/ai-vault/remote-session-parse-cache.ts b/src/main/ai-vault/remote-session-parse-cache.ts deleted file mode 100644 index afa420c92940..000000000000 --- a/src/main/ai-vault/remote-session-parse-cache.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' -import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat' - -// Matches the local scanner's cap. The relay sidecar is forked with -// --max-old-space-size=384, and a retained session row is a title, a preview -// window and counters — orders of magnitude smaller than the transcript it was -// parsed from, which is what the cache stops us re-reading. -const MAX_CACHE_ENTRIES = 4096 - -type RemoteSessionParseCacheEntry = { - mtimeMs: number - sizeBytes: number | null - hostKey: string - sidecar?: SessionSidecarObservation - session: AiVaultSession | null -} - -// Module scope so it outlives one scan: the sidecar is retired only after 10 -// idle minutes, so it spans many passes of a 30s cadence. -const cache = new Map() - -export type RemoteSessionParseStats = { reused: number; parsed: number } - -export function resetRemoteSessionParseCacheForTests(): void { - cache.clear() -} - -/** Identity of the host a parse result belongs to; a result is not portable across either field. */ -export function remoteSessionParseHostKey(context: RemoteScannerContext): string { - return `${context.executionHostId}\u0000${context.hostPlatform.relayPlatform}` -} - -export function remoteSessionCandidateKey(candidate: RemoteSessionCandidate): string { - return `${candidate.source.agent}\u0000${candidate.file.path}` -} - -function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void { - cache.delete(path) - cache.set(path, entry) - if (cache.size > MAX_CACHE_ENTRIES) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } - } -} - -/** - * Parse a remote transcript, reusing the previous result when the file is - * provably unchanged. - * - * Why this exists: the remote scanner had no cache of any kind, so every pass - * re-read and re-parsed the whole corpus — up to 3000 whole-file reads, GBs of - * JSONL, including July transcripts that had not changed in a month — which is - * what pegged the relay host on the renderer's 30s forced-rescan cadence - * (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly - * this reason; this is its remote counterpart. - * - * `(mtimeMs, sizeBytes)` covers the transcript, and the sidecar observation - * discovery records beside it (remote-session-scanner-discovery.ts) covers a - * source's companion file, so a metadata-only transcript whose companion - * changed still looks changed. Remote Cline is the only such source; remote - * Cursor streams transcript content with no sibling to read. Sources whose - * parse reads a file discovery does not stat — Codex looks its title up in - * `session_index.jsonl` — are not covered by either and pass - * `refreshReusedSession` to re-derive the uncovered part without touching the - * transcript. - * - * Only a completed parse is stored. A read that threw stays uncached so a - * transient filesystem failure cannot pin a wrong answer for the corpus's life. - */ -export async function parseRemoteSessionFileCached(args: { - candidate: RemoteSessionCandidate - hostKey: string - parse: () => Promise - // Applied to a reused session only; must not re-read the transcript. - refreshReusedSession?: (session: AiVaultSession) => Promise - stats?: RemoteSessionParseStats -}): Promise { - const { file } = args.candidate - const key = remoteSessionCandidateKey(args.candidate) - const entry = cache.get(key) - const unchanged = - entry !== undefined && - entry.hostKey === args.hostKey && - entry.mtimeMs === file.mtimeMs && - (entry.sizeBytes === null || - file.sizeBytes === undefined || - entry.sizeBytes === file.sizeBytes) && - sidecarUnchanged(entry.sidecar, file.sidecar) - if (unchanged) { - if (args.stats) { - args.stats.reused++ - } - if (entry.session && args.refreshReusedSession) { - entry.session = await args.refreshReusedSession(entry.session) - } - // Refresh recency without re-parsing so the LRU evicts cold paths first. - storeEntry(key, entry) - return entry.session - } - - const session = await args.parse() - if (args.stats) { - args.stats.parsed++ - } - storeEntry(key, { - mtimeMs: file.mtimeMs, - sizeBytes: file.sizeBytes ?? null, - hostKey: args.hostKey, - sidecar: file.sidecar, - session - }) - return session -} diff --git a/src/main/ai-vault/remote-session-scan-batching.test.ts b/src/main/ai-vault/remote-session-scan-batching.test.ts deleted file mode 100644 index da9ee87601c9..000000000000 --- a/src/main/ai-vault/remote-session-scan-batching.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapRemoteScanBatches } from './remote-session-scan-batching' - -describe('mapRemoteScanBatches', () => { - it('observes an abort that lands while the final batch yields', async () => { - const controller = new AbortController() - - await expect( - mapRemoteScanBatches( - ['a', 'b'], - 2, - async (item) => { - controller.abort() - return item - }, - controller.signal - ) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('observes an abort for empty inputs that never enter the loop', async () => { - const controller = new AbortController() - controller.abort() - - await expect( - mapRemoteScanBatches([], 8, async (item) => item, controller.signal) - ).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('returns every batch result when nothing cancels', async () => { - expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6]) - }) -}) diff --git a/src/main/ai-vault/remote-session-scan-batching.ts b/src/main/ai-vault/remote-session-scan-batching.ts deleted file mode 100644 index d51703e1b152..000000000000 --- a/src/main/ai-vault/remote-session-scan-batching.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' - -// Batching bounds concurrent remote round trips; the yield between batches keeps -// the process responsive, and the batch boundary is where cancellation lands. -export async function mapRemoteScanBatches( - items: readonly T[], - concurrency: number, - mapper: (item: T) => Promise, - signal?: AbortSignal -): Promise { - const results: U[] = [] - for (let index = 0; index < items.length; index += concurrency) { - throwIfAiVaultScanCancelled(signal) - results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper)))) - await yieldToEventLoop() - } - // An abort can land while the last batch yields, and empty inputs never enter - // the loop at all — observe it here so neither path returns as a success. - throwIfAiVaultScanCancelled(signal) - return results -} diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts deleted file mode 100644 index 7fe45d9f1a09..000000000000 --- a/src/main/ai-vault/remote-session-scan-concurrency.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' - -// Why: discovery batches (8 sources) each stat in batches of 8, and parse -// batches read whole transcripts — nested fan-out put ~64 filesystem round -// trips on one SSH mux or relay event loop at once, blowing the scan budget and -// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling. -const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8 - -/** Wraps a provider so all of one scan's filesystem calls share a single - * in-flight ceiling, regardless of how the callers nest their batches. */ -export function limitRemoteScanFilesystemConcurrency( - provider: RemoteSessionFilesystemProvider, - maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY -): RemoteSessionFilesystemProvider { - const gate = createConcurrencyGate(maxInFlight) - return { - openCode: provider.openCode, - readDir: (dirPath) => gate(() => provider.readDir(dirPath)), - readFile: (filePath, limits) => gate(() => provider.readFile(filePath, limits)), - stat: (filePath) => gate(() => provider.stat(filePath)), - ...(provider.readTranscriptBytes - ? { - readTranscriptBytes: async function* ( - path: string, - signal?: AbortSignal, - options?: { regularFileOnly: true; maxBytes: number } - ) { - let enter!: () => void - let release!: () => void - const entered = new Promise((resolve) => { - enter = resolve - }) - const released = new Promise((resolve) => { - release = resolve - }) - const held = gate(async () => { - enter() - await released - }) - await entered - try { - yield* provider.readTranscriptBytes!(path, signal, options) - } finally { - release() - await held - } - } - } - : {}) - } -} - -function createConcurrencyGate(maxInFlight: number): (run: () => Promise) => Promise { - const limit = Math.max(1, Math.floor(maxInFlight)) - const waiting: (() => void)[] = [] - let inFlight = 0 - - return async (run: () => Promise): Promise => { - if (inFlight < limit) { - inFlight++ - } else { - // The releasing call hands its slot over directly, so a queued caller can - // never race a fresh one into an over-limit slot. - await new Promise((resolve) => waiting.push(resolve)) - } - try { - return await run() - } finally { - const next = waiting.shift() - if (next) { - next() - } else { - inFlight-- - } - } - } -} diff --git a/src/main/ai-vault/remote-session-scanner-cline-source.ts b/src/main/ai-vault/remote-session-scanner-cline-source.ts deleted file mode 100644 index f568aa06ce91..000000000000 --- a/src/main/ai-vault/remote-session-scanner-cline-source.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { isMissingRemoteSessionPathError } from './remote-session-file-stat' -import type { RemoteSessionSource } from './remote-session-scanner-types' -import { - clineMessagesPathForMetadata, - isClineSessionMetadataPath, - parseClineSessionDocuments, - parseClineSessionContent -} from './session-scanner-cline-parser' - -export function remoteClineSource( - remoteHome: string, - hostPlatform: RemoteHostPlatform -): RemoteSessionSource { - return { - agent: 'cline', - rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'), - extensions: ['.json'], - filePredicate: isClineSessionMetadataPath, - contentDependencyPath: clineMessagesPathForMetadata, - directoryPredicate: (_name, depth) => depth === 0, - parseDocument: (file, bytes, context) => - parseClineSessionDocuments( - file, - bytes, - () => - context.provider.readTranscriptBytes!( - clineMessagesPathForMetadata(file.path), - context.signal - ), - context.hostPlatform.os, - { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }, - context.signal - ), - parse: async (file, content, context) => { - let messagesContent: string | null = null - try { - throwIfAiVaultScanCancelled(context.signal) - const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path)) - throwIfAiVaultScanCancelled(context.signal) - messagesContent = read.isBinary ? null : read.content - } catch (error) { - throwIfAiVaultScanCancelled(context.signal) - if (!isMissingRemoteSessionPathError(error)) { - throw error - } - } - return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }) - } - } -} diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts deleted file mode 100644 index 89951110cdf3..000000000000 --- a/src/main/ai-vault/remote-session-scanner-codex-index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' -import { remoteSessionContentLines } from './remote-session-content-lines' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import type { - RemoteScannerContext, - RemoteSessionFilesystemProvider -} from './remote-session-scanner-types' - -const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' - -// One index read per CODEX_HOME per scan (`context.titleCaches` is scan-scoped); -// used both by the transcript parse and by the parse cache's reuse path. -export function remoteCodexIndexedTitleReader( - codexHome: string, - context: RemoteScannerContext -): (sessionId: string) => Promise { - return async (sessionId) => - ( - await remoteCodexIndexTitles({ - provider: context.provider, - codexHome, - hostPlatform: context.hostPlatform, - titleCaches: context.titleCaches, - signal: context.signal - }) - ).get(sessionId) ?? null -} - -export async function remoteCodexIndexTitles(args: { - provider: RemoteSessionFilesystemProvider - codexHome: string - hostPlatform: RemoteHostPlatform - titleCaches: Map>> - signal?: AbortSignal -}): Promise> { - const cached = args.titleCaches.get(args.codexHome) - if (cached) { - return cached - } - const pending = readRemoteCodexIndexTitles( - args.provider, - args.codexHome, - args.hostPlatform, - args.signal - ) - args.titleCaches.set(args.codexHome, pending) - return pending -} - -async function readRemoteCodexIndexTitles( - provider: RemoteSessionFilesystemProvider, - codexHome: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise> { - const titleBySessionId = new Map() - try { - throwIfAiVaultScanCancelled(signal) - const { content, isBinary } = await provider.readFile( - joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE) - ) - throwIfAiVaultScanCancelled(signal) - if (isBinary) { - return titleBySessionId - } - for await (const line of remoteSessionContentLines(content, signal)) { - const record = parseJsonObject(line) - if (!record) { - continue - } - const sessionId = extractString(record.id) - const title = normalizeTitleText(extractString(record.thread_name) ?? '') - if (sessionId && title) { - titleBySessionId.set(sessionId, title) - } - } - } catch { - throwIfAiVaultScanCancelled(signal) - // Codex indexes are opportunistic; raw transcripts remain sufficient. - } - return titleBySessionId -} diff --git a/src/main/ai-vault/remote-session-scanner-codex-sources.ts b/src/main/ai-vault/remote-session-scanner-codex-sources.ts deleted file mode 100644 index 2e6175a445f2..000000000000 --- a/src/main/ai-vault/remote-session-scanner-codex-sources.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { joinRemotePath, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { parseCodexSessionContent } from './session-scanner-codex-parser' -import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' -import type { RemoteSessionContent } from './remote-session-content-lines' -import type { FileWithMtime } from './session-scanner-types' -import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types' - -/** Codex is the one remote agent whose sessions live under two CODEX_HOME roots, so it - * yields a source per root. Split from `remote-session-scanner-sources.ts` so adding an - * agent does not push that module past its line budget. */ -export function remoteCodexSources( - remoteHome: string, - hostPlatform: RemoteHostPlatform -): RemoteSessionSource[] { - return [ - joinRemotePath(hostPlatform, remoteHome, '.codex'), - joinRemotePath( - hostPlatform, - remoteHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - ].map((codexHome) => { - const parse = ( - file: FileWithMtime, - content: RemoteSessionContent, - context: RemoteScannerContext - ) => - parseCodexSessionContent({ - file, - content, - platform: context.hostPlatform.os, - codexHome, - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os, - signal: context.signal, - readIndexedTitle: remoteCodexIndexedTitleReader(codexHome, context) - }) - return { - agent: 'codex', - rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), - codexHome, - extensions: ['.jsonl'], - parse, - parseLines: parse - } - }) -} diff --git a/src/main/ai-vault/remote-session-scanner-devin-source.ts b/src/main/ai-vault/remote-session-scanner-devin-source.ts deleted file mode 100644 index 8244dd6e6252..000000000000 --- a/src/main/ai-vault/remote-session-scanner-devin-source.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { remoteSessionDocumentParsers } from './remote-session-document-parsers' -import type { RemoteSessionSource } from './remote-session-scanner-types' -import { parseDevinSessionContent } from './session-scanner-devin-parser' - -// Why: Devin CLI writes transcripts under %APPDATA% on a Windows host and -// ~/.local/share on posix ones. -function remoteDevinDataSegments(hostPlatform: RemoteHostPlatform): string[] { - return hostPlatform.os === 'win32' - ? ['AppData', 'Roaming', 'devin', 'cli'] - : ['.local', 'share', 'devin', 'cli'] -} - -export function remoteDevinSource( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - directory: 'transcripts' | 'agent_logs' = 'transcripts' -): RemoteSessionSource { - return { - agent: 'devin', - rootDir: joinRemotePath( - hostPlatform, - remoteHome, - ...remoteDevinDataSegments(hostPlatform), - directory - ), - extensions: ['.json'], - ...remoteSessionDocumentParsers('devin'), - parse: (file, content, context) => - Promise.resolve( - parseDevinSessionContent(file, content, context.hostPlatform.os, { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - }) - ) - } -} diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts deleted file mode 100644 index 9f01855097cf..000000000000 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { extname } from 'node:path' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../shared/execution-host' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' -import type { FileWithMtime } from './session-scanner-types' -import type { SessionSidecarObservation } from './session-sidecar-stat' -import { errorMessage } from './session-scanner-values' -import { mapRemoteScanBatches } from './remote-session-scan-batching' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordSessionScanIssue } from './session-scan-issues' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionSource -} from './remote-session-scanner-types' - -const REMOTE_DISCOVERY_CONCURRENCY = 8 - -export async function discoverRemoteSourceCandidates(args: { - source: RemoteSessionSource - context: RemoteScannerContext - issues: AiVaultScanIssue[] -}): Promise { - if (args.source.discover) { - const files = await args.source.discover(args.context, args.issues) - throwIfAiVaultScanCancelled(args.context.signal) - return files.map((file) => ({ source: args.source, file })) - } - const walked = args.source.fixedChildFileSegments - ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) - : await walkRemoteSessionFiles(args.source, args.context, args.issues) - const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null - const paths = partition ? partition.sessionFilePaths : walked - const files = await mapRemoteScanBatches( - paths, - REMOTE_DISCOVERY_CONCURRENCY, - (path) => statRemoteCandidateFile(path, args.source, args.context, args.issues), - args.context.signal - ) - return files - .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ - source: args.source, - file, - subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 - })) -} - -async function statRemoteCandidateFile( - path: string, - source: RemoteSessionSource, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - const file = await statRemoteSessionFile( - context.provider, - path, - source.agent, - context.executionHostId, - issues, - { - missingIsExpected: Boolean(source.fixedChildFileSegments), - signal: context.signal - } - ) - if (!file || !source.contentDependencyPath) { - return file - } - const sidecarPath = source.contentDependencyPath(path) - // Recorded beside the transcript's own stat, never folded into it: one key - // cannot mean both "the transcript grew" and "the sibling changed". - return { ...file, sidecar: await observeRemoteSidecar(source, context, sidecarPath, issues) } -} - -/** - * A stat that failed for any reason other than a missing path is `'unknown'`, - * not `'none'`: serving the cached session over an unreadable sibling would - * publish metadata nobody can currently see. `statRemoteSessionFile` already - * recorded the issue for the failure. - */ -async function observeRemoteSidecar( - source: RemoteSessionSource, - context: RemoteScannerContext, - sidecarPath: string, - issues: AiVaultScanIssue[] -): Promise { - try { - const sidecar = await statRemoteSessionFile( - context.provider, - sidecarPath, - source.agent, - context.executionHostId, - issues, - { missingIsExpected: true, signal: context.signal, rethrowFailures: true } - ) - return sidecar - ? { path: sidecarPath, mtimeMs: sidecar.mtimeMs, sizeBytes: sidecar.sizeBytes ?? 0 } - : 'none' - } catch { - return 'unknown' - } -} - -async function listRemoteFixedChildFiles( - source: RemoteSessionSource, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - throwIfAiVaultScanCancelled(context.signal) - let entries - try { - entries = await context.provider.readDir(source.rootDir) - } catch (err) { - throwIfAiVaultScanCancelled(context.signal) - recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) - return [] - } - const segments = source.fixedChildFileSegments ?? [] - // Why: Antigravity's transcript path is fixed. Constructing it avoids three - // serialized SSH readDir round trips for every conversation directory. - return entries - .filter((entry) => entry.isDirectory && !entry.isSymlink) - .flatMap((entry) => - [segments, ...(source.additionalFixedChildFileSegments ?? [])].map((fileSegments) => - joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...fileSegments) - ) - ) - .filter((path) => source.filePredicate?.(path) ?? true) -} - -async function walkRemoteSessionFiles( - source: RemoteSessionSource, - context: RemoteScannerContext, - issues: AiVaultScanIssue[], - dirPath = source.rootDir, - depth = 0 -): Promise { - throwIfAiVaultScanCancelled(context.signal) - let entries - try { - entries = await context.provider.readDir(dirPath) - } catch (err) { - throwIfAiVaultScanCancelled(context.signal) - recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) - return [] - } - - const extensions = new Set(source.extensions) - const files: string[] = [] - for (const entry of entries) { - throwIfAiVaultScanCancelled(context.signal) - const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) - if ( - entry.isDirectory && - !entry.isSymlink && - (source.directoryPredicate?.(entry.name, depth) ?? true) - ) { - files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) - continue - } - if ( - !entry.isSymlink && - extensions.has(extname(entry.name).toLowerCase()) && - (source.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) - } - } - return files -} - -function recordRemoteDirectoryIssue( - source: RemoteSessionSource, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[], - path: string, - err: unknown -): void { - if (!isMissingRemoteSessionPathError(err)) { - recordSessionScanIssue(issues, { - executionHostId, - agent: source.agent, - kind: 'host', - path, - message: errorMessage(err) - }) - } -} diff --git a/src/main/ai-vault/remote-session-scanner-kiro-source.ts b/src/main/ai-vault/remote-session-scanner-kiro-source.ts deleted file mode 100644 index 5362d4033f55..000000000000 --- a/src/main/ai-vault/remote-session-scanner-kiro-source.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { isMissingRemoteSessionPathError } from './remote-session-file-stat' -import { - remoteSessionContentLines, - streamedSessionContentLines -} from './remote-session-content-lines' -import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types' -import { - isKiroSessionMetadataPath, - kiroTranscriptPathForMetadata, - parseKiroSessionContent -} from './session-scanner-kiro-parser' - -async function readTranscriptLines( - metadataPath: string, - context: RemoteScannerContext -): Promise | AsyncIterable | null> { - const transcriptPath = kiroTranscriptPathForMetadata(metadataPath) - // Why: beside the execution host the transcript streams; megabyte tool outputs are common. - if (context.provider.readTranscriptBytes) { - return streamedSessionContentLines( - context.provider.readTranscriptBytes(transcriptPath, context.signal), - context.signal - ) - } - try { - throwIfAiVaultScanCancelled(context.signal) - const read = await context.provider.readFile(transcriptPath) - throwIfAiVaultScanCancelled(context.signal) - return read.isBinary ? null : remoteSessionContentLines(read.content, context.signal) - } catch (error) { - throwIfAiVaultScanCancelled(context.signal) - if (isMissingRemoteSessionPathError(error)) { - return null - } - throw error - } -} - -export function remoteKiroSource( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - kiroHomeDir?: string -): RemoteSessionSource { - return { - agent: 'kiro', - rootDir: kiroHomeDir - ? joinRemotePath(hostPlatform, kiroHomeDir, 'sessions', 'cli') - : joinRemotePath(hostPlatform, remoteHome, '.kiro', 'sessions', 'cli'), - extensions: ['.json'], - filePredicate: isKiroSessionMetadataPath, - contentDependencyPath: kiroTranscriptPathForMetadata, - directoryPredicate: () => false, - parse: async (file, content, context) => { - const options = { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - } - try { - const lines = await readTranscriptLines(file.path, context) - return await parseKiroSessionContent(file, content, lines, context.hostPlatform.os, options) - } catch (error) { - // A transcript that vanished mid-stream leaves a valid metadata-only session. - if (!isMissingRemoteSessionPathError(error)) { - throw error - } - return parseKiroSessionContent(file, content, null, context.hostPlatform.os, options) - } - } - } -} diff --git a/src/main/ai-vault/remote-session-scanner-kiro.test.ts b/src/main/ai-vault/remote-session-scanner-kiro.test.ts deleted file mode 100644 index 78700bbe370a..000000000000 --- a/src/main/ai-vault/remote-session-scanner-kiro.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import { KIRO_FIXTURE_SESSION_ID } from './session-scanner-kiro-fixtures' - -afterEach(() => vi.unstubAllEnvs()) - -describe('remote Kiro history', () => { - it.each([ - ['linux-x64', '/home/ada', '/srv/kiro'], - ['win32-x64', 'C:/Users/Ada', 'D:/kiro'] - ] as const)( - 'reads the %s execution host override, independent of the client', - async (platform, remoteHome, kiroHomeDir) => { - vi.stubEnv('KIRO_HOME', '/client/must-not-be-read') - const provider = new MemoryRemoteProvider() - const path = `${kiroHomeDir}/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json` - provider.addFile(path, JSON.stringify({ cwd: remoteHome, title: 'Remote Kiro' }), 10) - provider.addFile( - `${path}l`, - jsonLines([ - { kind: 'Prompt', data: { content: [{ kind: 'text', data: 'Read the file' }] } }, - { kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'Done' }] } } - ]), - 11 - ) - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:kiro-host', - remoteHome, - kiroHomeDir, - hostPlatform: getRemoteHostPlatform(platform) - }) - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'kiro', - sessionId: KIRO_FIXTURE_SESSION_ID, - filePath: path, - messageCount: 2, - executionHostId: 'ssh:kiro-host' - }) - } - ) - - it('keeps the remote default when the peer supplies no override', async () => { - vi.stubEnv('KIRO_HOME', '/client/must-not-be-read') - const provider = new MemoryRemoteProvider() - const path = `/home/ada/.kiro/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json` - provider.addFile(path, JSON.stringify({ cwd: '/home/ada', title: 'Remote Kiro' }), 10) - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:kiro-host', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - expect(result.issues).toEqual([]) - expect(result.sessions[0]?.filePath).toBe(path) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-muse.test.ts b/src/main/ai-vault/remote-session-scanner-muse.test.ts deleted file mode 100644 index 3d94fc1eee4c..000000000000 --- a/src/main/ai-vault/remote-session-scanner-muse.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' - -describe('scanRemoteAiVaultSessions muse', () => { - it('discovers Muse transcripts under the remote XDG sessions root', async () => { - const provider = new MemoryRemoteProvider() - const sessionDir = '/home/ada/.local/share/muse/sessions/2026/07/04/muse-remote' - provider.addFile( - `${sessionDir}/session.jsonl`, - jsonLines([ - { - record_type: 'event', - payload_type: 'runtime.session.metadata', - recorded_at: 1780000000000000, - payload: { kind: 'metadata', record: { workspace_root: '/home/ada/repo' } } - }, - { - record_type: 'event', - payload_type: 'runtime.user_intent.accepted', - recorded_at: 1780000001000000, - payload: { - intent_id: 'intent-remote', - refill_blocks: [{ kind: 'text', text: 'Remote muse title' }] - } - }, - { - record_type: 'event', - payload_type: 'runtime.session', - recorded_at: 1780000002000000, - payload: { - kind: 'run', - run_id: 'run-remote', - event: { - kind: 'model_completed', - model: 'muse-spark-remote', - usage: { input_tokens: 3, output_tokens: 4 } - } - } - } - ]), - 40 - ) - // Sidecars next to the transcript must not list as sessions. - provider.addFile(`${sessionDir}/cli-abc.log`, 'log output', 41) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'muse', - sessionId: 'muse-remote', - title: 'Remote muse title', - model: 'muse-spark-remote', - filePath: `${sessionDir}/session.jsonl` - }) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts deleted file mode 100644 index 8185765e4d22..000000000000 --- a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' - -describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => { - it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => { - const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' - const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0' - const provider = new MemoryRemoteProvider() - provider.addFile( - `${workspaceDir}/${stem}.jsonl`, - jsonLines([ - { - type: 'session', - version: 3, - id: 'cccccccc-dddd-4eee-8fff-000000000000', - cwd: '/home/ada/repo', - timestamp: '2026-05-01T10:00:00.000Z' - }, - { - type: 'message', - timestamp: '2026-05-01T10:00:01.000Z', - message: { role: 'user', content: 'Delegate some tasks' } - } - ]), - 70 - ) - for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) { - provider.addFile( - `${workspaceDir}/${stem}/${label}.jsonl`, - jsonLines([ - { - type: 'message', - timestamp: '2026-05-01T10:01:00.000Z', - message: { role: 'user', content: `${label} task prompt` } - } - ]), - 71 - ) - } - // A grandchild belongs to its own parent: neither a row nor a count here. - provider.addFile( - `${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`, - jsonLines([ - { - type: 'message', - timestamp: '2026-05-01T10:02:00.000Z', - message: { role: 'user', content: 'Nested task prompt' } - } - ]), - 72 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'omp', - sessionId: 'cccccccc-dddd-4eee-8fff-000000000000', - subagentTranscriptCount: 2, - filePath: `${workspaceDir}/${stem}.jsonl` - }) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-opencode-source.ts b/src/main/ai-vault/remote-session-scanner-opencode-source.ts deleted file mode 100644 index c42f4b6cb090..000000000000 --- a/src/main/ai-vault/remote-session-scanner-opencode-source.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { isMissingRemoteSessionPathError } from './remote-session-file-stat' -import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' -import { remotePathSegments } from './remote-session-scanner-source-parsers' -import type { - RemoteOpenCodeSessionReader, - RemoteScannerContext, - RemoteSessionSource -} from './remote-session-scanner-types' -import { recordSessionScanIssue } from './session-scan-issues' -import { restampAiVaultListResult } from './session-list-results' -import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' -import { errorMessage } from './session-scanner-values' - -export function remoteOpenCodeSources( - reader: RemoteOpenCodeSessionReader | undefined, - candidateLimit: number -): RemoteSessionSource[] { - if (!reader) { - return [] - } - let databasePaths: Promise | undefined - return (['opencode', 'opencode2'] as const).map((agent) => { - const source: RemoteSessionSource = { - agent, - rootDir: reader.dataDirectory, - extensions: ['.json'], - parse: async () => null, - parseCandidate: async (file, context) => { - throwIfAiVaultScanCancelled(context.signal) - const candidate = splitOpenCodeSqliteCandidate(file.path) - const session = candidate - ? await reader.parse({ - ...candidate, - platform: context.hostPlatform.os, - ...(agent === 'opencode2' ? { agent } : {}), - signal: context.signal - }) - : await reader.parseLegacy(file, context.hostPlatform.os) - throwIfAiVaultScanCancelled(context.signal) - if (!session) { - return null - } - const stamped = restampAiVaultListResult( - { sessions: [session], issues: [], scannedAt: '' }, - context.executionHostId - ).sessions[0] - return stamped ? { ...stamped, executionHostPlatform: context.hostPlatform.os } : null - }, - discover: async (context, issues) => { - databasePaths ??= discoverDatabasePaths(reader, context, issues) - const paths = await databasePaths - const ownPaths = - agent === 'opencode2' - ? paths - : paths.filter( - (path) => !isOpenCodeV2DatabaseName(remotePathSegments(path).at(-1) ?? '') - ) - const readIssues: AiVaultScanIssue[] = [] - const candidates = await reader.list({ - dbPaths: ownPaths, - limit: candidateLimit, - issues: readIssues, - ...(agent === 'opencode2' ? { agent } : {}), - signal: context.signal - }) - throwIfAiVaultScanCancelled(context.signal) - for (const issue of readIssues) { - recordSessionScanIssue(issues, { ...issue, executionHostId: context.executionHostId }) - } - const files = candidates.map((candidate) => candidate.file) - if (agent === 'opencode2') { - return files - } - const legacy = await discoverRemoteSourceCandidates({ - source: { - ...source, - discover: undefined, - rootDir: joinRemotePath( - context.hostPlatform, - reader.dataDirectory, - 'storage', - 'session' - ) - }, - context, - issues - }) - const ids = new Set(files.map((file) => splitOpenCodeSqliteCandidate(file.path)?.sessionId)) - return [ - ...files, - ...legacy - .filter( - ({ file }) => - !ids.has( - remotePathSegments(file.path) - .at(-1) - ?.replace(/\.json$/, '') - ) - ) - .map(({ file }) => file) - ] - } - } - return source - }) -} - -async function discoverDatabasePaths( - reader: RemoteOpenCodeSessionReader, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - throwIfAiVaultScanCancelled(context.signal) - if (reader.databasePath === null) { - return [] - } - try { - if (reader.databasePath !== undefined) { - const stat = await context.provider.stat(reader.databasePath) - return stat.type === 'file' || stat.type === 'symlink' ? [reader.databasePath] : [] - } - const entries = await context.provider.readDir(reader.dataDirectory) - return entries - .filter( - (entry) => - !entry.isDirectory && - !entry.isSymlink && - /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name) - ) - .map((entry) => joinRemotePath(context.hostPlatform, reader.dataDirectory, entry.name)) - .sort() - } catch (error) { - throwIfAiVaultScanCancelled(context.signal) - if (!isMissingRemoteSessionPathError(error)) { - recordSessionScanIssue(issues, { - executionHostId: context.executionHostId, - agent: 'opencode', - kind: 'scope', - path: reader.databasePath ?? reader.dataDirectory, - message: errorMessage(error) - }) - } - return [] - } -} diff --git a/src/main/ai-vault/remote-session-scanner-opencode.test.ts b/src/main/ai-vault/remote-session-scanner-opencode.test.ts deleted file mode 100644 index 573ee1939cf4..000000000000 --- a/src/main/ai-vault/remote-session-scanner-opencode.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import type { RemoteOpenCodeSessionReader } from './remote-session-scanner-types' -import { createAccumulator, finalizeSession, updateTimeline } from './session-scanner-accumulator' -import { - buildOpenCodeSqliteCandidatePath, - splitOpenCodeSqliteCandidate -} from './session-scanner-opencode-sqlite-paths' -import type { FileWithMtime } from './session-scanner-types' - -const remoteHome = '/home/ada' -const dataDirectory = `${remoteHome}/.local/share/opencode` -const hostPlatform = getRemoteHostPlatform('linux-x64') -const scanOptions = { remoteHome, hostPlatform, executionHostId: 'ssh:one' as const } - -beforeEach(resetRemoteSessionParseCacheForTests) - -function fixture() { - const sessions = new Map() - const list: RemoteOpenCodeSessionReader['list'] = vi.fn(async (args) => - [...sessions.values()] - .filter( - (session) => - session.agent === (args.agent ?? 'opencode') && args.dbPaths.includes(session.filePath) - ) - .sort((left, right) => Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt)) - .slice(0, args.limit) - .map((session) => ({ - agent: session.agent, - codexHome: null, - file: file( - buildOpenCodeSqliteCandidatePath(session.filePath, session.sessionId), - Date.parse(session.modifiedAt) - ) - })) - ) - const parse: RemoteOpenCodeSessionReader['parse'] = vi.fn( - async (args) => - sessions.get( - `${args.agent ?? 'opencode'}:${buildOpenCodeSqliteCandidatePath(args.dbPath, args.sessionId)}` - ) ?? null - ) - const parseLegacy = vi.fn(async (entry: FileWithMtime) => - session('opencode', 'legacy', entry, '/home/ada/repo') - ) - const provider = Object.assign(new MemoryRemoteProvider(), { - openCode: { dataDirectory, list, parse, parseLegacy } - }) - const add = ( - id: string, - mtime: number, - options: { agent?: 'opencode' | 'opencode2'; cwd?: string; dbName?: string } = {} - ) => { - const agent = options.agent ?? 'opencode' - const dbPath = `${dataDirectory}/${options.dbName ?? 'opencode.db'}` - provider.addFile(dbPath, 'database', 1) - const path = buildOpenCodeSqliteCandidatePath(dbPath, id) - sessions.set( - `${agent}:${path}`, - session(agent, id, file(path, mtime), options.cwd ?? '/home/ada/other') - ) - } - return { provider, add, list, parse, parseLegacy } -} - -function file(path: string, mtimeMs: number): FileWithMtime { - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } -} - -function session( - agent: 'opencode' | 'opencode2', - sessionId: string, - entry: FileWithMtime, - cwd: string -): AiVaultSession { - const accumulator = createAccumulator({ - agent, - file: { ...entry, path: splitOpenCodeSqliteCandidate(entry.path)?.dbPath ?? entry.path }, - sessionId - }) - accumulator.cwd = cwd - updateTimeline(accumulator, entry.modifiedAt) - const parsed = finalizeSession(accumulator, 'linux') - if (!parsed) { - throw new Error('Invalid fixture session') - } - return parsed -} - -describe('remote OpenCode discovery', () => { - it('sorts database rows with file-backed agents before applying the global cap', async () => { - const { provider, add } = fixture() - add('older', 1000) - add('newest', 3000) - provider.addFile( - `${remoteHome}/.claude/projects/project/middle.jsonl`, - jsonLines([ - { - sessionId: 'middle', - timestamp: new Date(2000).toISOString(), - type: 'user', - message: { content: 'middle' } - } - ]), - 2000 - ) - const read = vi.spyOn(provider, 'readFile') - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 2 }) - expect(result.issues).toEqual([]) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['newest', 'middle']) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:one', - executionHostPlatform: 'linux', - id: `ssh:one:opencode:newest:${dataDirectory}/opencode.db` - }) - expect(read.mock.calls.every(([path]) => !path.includes('.db#'))).toBe(true) - }) - - it('retains older scoped rows beyond the recent parse budget', async () => { - const { provider, add, list } = fixture() - for (let index = 0; index < 10; index++) { - add(`other-${index}`, 10000 + index) - } - add('scoped', 1000, { cwd: '/home/ada/repo/app' }) - const result = await scanRemoteAiVaultSessions({ - ...scanOptions, - provider, - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['other-9', 'scoped']) - expect(list).toHaveBeenCalledWith(expect.objectContaining({ limit: 1003 })) - }) - - it('drops stale legacy duplicates before limiting while keeping legacy-only history', async () => { - const { provider, add, parseLegacy } = fixture() - add('migrated', 2000) - provider.addFile(`${dataDirectory}/storage/session/project/migrated.json`, '{}', 9000) - provider.addFile(`${dataDirectory}/storage/session/project/legacy.json`, '{}', 1000) - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 10 }) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['migrated', 'legacy']) - expect(parseLegacy).toHaveBeenCalledTimes(1) - expect(parseLegacy).toHaveBeenCalledWith( - expect.objectContaining({ path: expect.stringContaining('/legacy.json') }), - 'linux' - ) - }) - - it('keeps v1 and v2 identities separate in a shared database and parse cache', async () => { - const { provider, add, parse, list } = fixture() - add('same-id', 2000) - add('same-id', 2000, { agent: 'opencode2' }) - add('beta', 1000, { agent: 'opencode2', dbName: 'opencode-next.db' }) - const first = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - const second = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(first.sessions.map((entry) => entry.agent).sort()).toEqual([ - 'opencode', - 'opencode2', - 'opencode2' - ]) - expect(second.sessions).toEqual(first.sessions) - expect(parse).toHaveBeenCalledTimes(3) - expect(list).toHaveBeenCalledWith( - expect.objectContaining({ dbPaths: [`${dataDirectory}/opencode.db`] }) - ) - expect(list).toHaveBeenCalledWith( - expect.objectContaining({ - dbPaths: [`${dataDirectory}/opencode-next.db`, `${dataDirectory}/opencode.db`], - agent: 'opencode2' - }) - ) - }) - - it('invalidates cached database rows after updates and across hosts', async () => { - const { provider, add, parse } = fixture() - add('session', 1000) - await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - add('session', 2000) - const updated = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - const other = await scanRemoteAiVaultSessions({ - ...scanOptions, - executionHostId: 'ssh:two', - provider - }) - expect(parse).toHaveBeenCalledTimes(3) - expect(updated.sessions[0]?.modifiedAt).toBe(new Date(2000).toISOString()) - expect(other.sessions[0]?.executionHostId).toBe('ssh:two') - }) - - it('propagates cancellation into the reader and does not cache cancelled parses', async () => { - const { provider, add, parse } = fixture() - add('session', 1000) - const controller = new AbortController() - provider.openCode.parse = async (args) => { - expect(args.signal).toBe(controller.signal) - controller.abort() - return parse(args) - } - await expect( - scanRemoteAiVaultSessions({ ...scanOptions, provider, signal: controller.signal }) - ).rejects.toMatchObject({ name: 'AbortError' }) - provider.openCode.parse = parse - await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(parse).toHaveBeenCalledTimes(2) - }) - - it('leaves legacy filesystem providers on their existing fallback', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile(`${dataDirectory}/opencode.db`, 'database', 1000) - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([]) - expect(provider.readDirPaths).not.toContain(dataDirectory) - }) - - it('honors an explicitly configured database symlink without enumerating sibling databases', async () => { - const { provider, add, list } = fixture() - add('selected', 1000, { dbName: 'opencode-team.db' }) - add('other', 2000) - const selected = `${dataDirectory}/opencode-team.db` - Object.assign(provider.openCode, { databasePath: selected }) - const stat = provider.stat.bind(provider) - vi.spyOn(provider, 'stat').mockImplementation(async (path) => - path === selected ? { type: 'symlink', size: 20, mtime: 1000, mtimeMs: 1000 } : stat(path) - ) - const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider }) - expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['selected']) - expect(list).toHaveBeenCalledWith(expect.objectContaining({ dbPaths: [selected] })) - expect(provider.readDirPaths).not.toContain(dataDirectory) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-source-parsers.ts b/src/main/ai-vault/remote-session-scanner-source-parsers.ts deleted file mode 100644 index c28b69319c25..000000000000 --- a/src/main/ai-vault/remote-session-scanner-source-parsers.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' -import { - parseMuseSessionContent, - parseMuseSessionRemoteContent -} from './session-scanner-muse-parser' -import type { FileWithMtime } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' -import type { RemoteSessionContent } from './remote-session-content-lines' -import type { RemoteParserOptions } from './remote-session-scanner-types' - -export function parseMuseRemoteContent( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - if (typeof content === 'string') { - return Promise.resolve(parseMuseSessionContent(file, content, platform, options)) - } - return parseMuseSessionRemoteContent(file, content, platform, options, signal) -} - -export function piParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) -} - -export function ompParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) -} - -export function primeAgentParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal) -} - -export function openClawParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) -} - -export function remotePathSegments(path: string): string[] { - return path.replace(/\\/g, '/').split('/').filter(Boolean) -} - -export function remotePiSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) -} - -export function remoteOmpSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) -} - -// Remote roots are POSIX regardless of the client platform. -export function remotePrimeAgentSessionsSegments(): string[] { - return ['.prime', 'agent', 'sessions'] -} diff --git a/src/main/ai-vault/remote-session-scanner-sources.test.ts b/src/main/ai-vault/remote-session-scanner-sources.test.ts deleted file mode 100644 index aa492565ee46..000000000000 --- a/src/main/ai-vault/remote-session-scanner-sources.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import type { RelayPlatform } from '../ssh/relay-protocol' -import { remoteSessionSources } from './remote-session-scanner-sources' - -function devinRootDirs(relayPlatform: RelayPlatform, remoteHome: string): string[] { - const hostPlatform: RemoteHostPlatform = getRemoteHostPlatform(relayPlatform) - return remoteSessionSources(remoteHome, hostPlatform) - .filter((source) => source.agent === 'devin') - .map((source) => source.rootDir) -} - -describe('remoteSessionSources devin transcripts root', () => { - it.each([ - { - relayPlatform: 'win32-x64' as const, - remoteHome: 'C:/Users/dev', - expected: 'C:/Users/dev/AppData/Roaming/devin/cli/transcripts' - }, - { - relayPlatform: 'linux-x64' as const, - remoteHome: '/home/dev', - expected: '/home/dev/.local/share/devin/cli/transcripts' - }, - { - relayPlatform: 'darwin-arm64' as const, - remoteHome: '/Users/dev', - expected: '/Users/dev/.local/share/devin/cli/transcripts' - } - ])('resolves $expected on $relayPlatform', ({ relayPlatform, remoteHome, expected }) => { - expect(devinRootDirs(relayPlatform, remoteHome)).toEqual([ - expected, - expected.replace(/transcripts$/, 'agent_logs') - ]) - }) -}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts deleted file mode 100644 index 7503348c33e8..000000000000 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { - ANTIGRAVITY_HISTORY_ROOTS, - type AntigravitySessionOrigin -} from '../../shared/antigravity-session-origin' -import { parseQoderSessionContent } from './session-scanner-qoder-parser' -import { remoteSessionDocumentParsers } from './remote-session-document-parsers' -import type { RemoteSessionContent } from './remote-session-content-lines' -import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' -import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' -import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' -import { parseDroidSessionContent } from './session-scanner-droid-parser' -import { parseClaudeSessionContent } from './session-scanner-primary-parsers' -import { parseCodebuddySessionContent } from './session-scanner-codebuddy-parser' -import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' -import { parseCopilotSessionContent } from './session-scanner-copilot-parser' -import { parseCursorSessionContent } from './session-scanner-cursor-parser' -import { parseHermesSessionContent } from './session-scanner-hermes-parser' -import { parseJcodeSessionContent } from './session-scanner-jcode-parser' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' -import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' -import { - ompParser, - openClawParser, - parseMuseRemoteContent, - piParser, - primeAgentParser, - remoteOmpSessionsSegments, - remotePathSegments, - remotePiSessionsSegments, - remotePrimeAgentSessionsSegments -} from './remote-session-scanner-source-parsers' -import type { FileWithMtime } from './session-scanner-types' -import { openClawSessionDirectoryPredicate } from './openclaw-session-layout' -import { remoteCodexSources } from './remote-session-scanner-codex-sources' -import { remoteClineSource } from './remote-session-scanner-cline-source' -import { remoteKiroSource } from './remote-session-scanner-kiro-source' -import { remoteDevinSource } from './remote-session-scanner-devin-source' -import type { - RemoteParserOptions, - RemoteScannerContext, - RemoteSessionSource -} from './remote-session-scanner-types' - -type RemoteContentParser = ( - file: FileWithMtime, - content: T, - platform: NodeJS.Platform, - options: RemoteParserOptions, - // Line-based parsers iterate cancellably; whole-document parsers ignore it. - signal?: AbortSignal -) => Promise | AiVaultSession | null - -export function remoteSessionSources( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - includeAntigravityIdeSessions = false, - kiroHomeDir?: string -): RemoteSessionSource[] { - return [ - ...remoteCodexSources(remoteHome, hostPlatform), - { - ...jsonlSource( - 'claude', - remoteHome, - hostPlatform, - ['.claude', 'projects'], - parseClaudeSessionContent - ), - // The remote host owns the transcript disk, so the local readdir in the - // Claude parser is skipped; the walked listing supplies the sibling - // subagent counts instead. Partitioning also prunes the subagent - // transcripts themselves, which would otherwise list as phantom - // top-level sessions carrying the parent's sessionId. - partitionSubagentTranscripts: partitionSubagentTranscriptPaths - }, - { - // The execution host owns CodeBuddy's transcripts, including subagent pruning. - ...jsonlSource( - 'codebuddy', - remoteHome, - hostPlatform, - ['.codebuddy', 'projects'], - parseCodebuddySessionContent - ), - partitionSubagentTranscripts: partitionSubagentTranscriptPaths - }, - { - ...jsonlSource( - 'qoder', - remoteHome, - hostPlatform, - ['.qoder', 'projects'], - parseQoderSessionContent - ), - partitionSubagentTranscripts: partitionSubagentTranscriptPaths - }, - ...(includeAntigravityIdeSessions - ? ANTIGRAVITY_HISTORY_ROOTS - : (['antigravity-cli'] as const) - ).map((origin) => remoteAntigravitySource(remoteHome, hostPlatform, origin)), - source( - 'gemini', - remoteHome, - hostPlatform, - ['.gemini', 'tmp'], - ['.json', '.jsonl'], - parseGeminiSessionContent - ), - jsonlSource( - 'copilot', - remoteHome, - hostPlatform, - ['.copilot', 'session-state'], - parseCopilotSessionContent - ), - jsonlSource( - 'cursor', - remoteHome, - hostPlatform, - ['.cursor', 'projects'], - parseCursorSessionContent, - (path) => remotePathSegments(path).includes('agent-transcripts') - ), - remoteClineSource(remoteHome, hostPlatform), - remoteKiroSource(remoteHome, hostPlatform, kiroHomeDir), - source( - 'hermes', - remoteHome, - hostPlatform, - ['.hermes', 'sessions'], - ['.json'], - parseHermesSessionContent - ), - remoteDevinSource(remoteHome, hostPlatform), - remoteDevinSource(remoteHome, hostPlatform, 'agent_logs'), - source( - 'jcode', - remoteHome, - hostPlatform, - ['.jcode', 'sessions'], - ['.json'], - parseJcodeSessionContent, - (path) => remotePathSegments(path).pop()?.startsWith('session_') === true - ), - jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), - { - ...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), - // Same posture as Claude above: OMP stores task-subagent transcripts in - // the session's same-named artifact dir; the walk supplies counts and the - // partition keeps the children out of the top-level list (#9330). - partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths - }, - jsonlSource( - 'prime-agent', - remoteHome, - hostPlatform, - remotePrimeAgentSessionsSegments(), - primeAgentParser - ), - jsonlSource( - 'muse', - remoteHome, - hostPlatform, - ['.local', 'share', 'muse', 'sessions'], - parseMuseRemoteContent, - // Why: each session dir holds session.jsonl plus .log/.sqlite3 sidecars; - // match only the transcript (same predicate as local discovery). - (path) => remotePathSegments(path).at(-1) === 'session.jsonl' - ), - jsonlSource( - 'droid', - remoteHome, - hostPlatform, - ['.factory', 'sessions'], - parseDroidSessionContent - ), - jsonlSource( - 'droid', - remoteHome, - hostPlatform, - ['.factory', 'projects'], - parseDroidSessionContent - ), - ...remoteOpenClawSources(remoteHome, hostPlatform) - ] -} - -function remoteAntigravitySource( - remoteHome: string, - hostPlatform: RemoteHostPlatform, - origin: AntigravitySessionOrigin -): RemoteSessionSource { - const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', origin) - const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') - const parse = async ( - file: FileWithMtime, - content: RemoteSessionContent, - context: RemoteScannerContext - ) => { - const session = await parseAntigravitySessionContent( - file, - content, - context.hostPlatform.os, - parserOptions(context), - context.signal - ) - return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null - } - return { - agent: 'antigravity', - rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), - extensions: ['.jsonl'], - filePredicate: isAntigravityTranscriptPath, - fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], - additionalFixedChildFileSegments: [['.system_generated', 'logs', 'transcript_full.jsonl']], - parse, - parseLines: parse - } -} - -function source( - agent: AiVaultAgent, - remoteHome: string, - hostPlatform: RemoteHostPlatform, - segments: readonly string[], - extensions: readonly string[], - parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean, - directoryPredicate?: (name: string, depth: number) => boolean -): RemoteSessionSource { - return { - agent, - rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), - extensions, - filePredicate, - directoryPredicate, - ...remoteSessionDocumentParsers(agent), - parse: (file, content, context) => - Promise.resolve( - parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal) - ) - } -} - -function jsonlSource( - agent: AiVaultAgent, - remoteHome: string, - hostPlatform: RemoteHostPlatform, - segments: readonly string[], - parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean -): RemoteSessionSource { - return { - ...source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate), - parseLines: (file, lines, context) => - Promise.resolve( - parseContent(file, lines, context.hostPlatform.os, parserOptions(context), context.signal) - ) - } -} - -function remoteOpenClawSources( - remoteHome: string, - hostPlatform: RemoteHostPlatform -): RemoteSessionSource[] { - return ['.openclaw', '.clawdbot'].map((rootName) => ({ - ...jsonlSource( - 'openclaw', - remoteHome, - hostPlatform, - [rootName, 'agents'], - openClawParser, - (path) => remotePathSegments(path).includes('sessions') - ), - directoryPredicate: openClawSessionDirectoryPredicate - })) -} - -function parserOptions(context: RemoteScannerContext): RemoteParserOptions { - return { - executionHostId: context.executionHostId, - executionHostPlatform: context.hostPlatform.os - } -} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts deleted file mode 100644 index a36e0c20a84c..000000000000 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ /dev/null @@ -1,823 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' -import { primeAgentFixture } from './session-scanner-prime-agent-fixtures' - -describe('scanRemoteAiVaultSessions', () => { - it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = '1786466194549_xrzrl' - const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - version: 1, - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - provider: 'deepseek', - model: 'deepseek-v4-flash', - cwd: '/home/ada/repo' - }), - 10 - ) - provider.addFile( - `${sessionDir}/${sessionId}.messages.json`, - JSON.stringify({ - version: 1, - updated_at: '2026-08-11T16:38:00.000Z', - sessionId, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'Fix remote Cline history' }] - } - ] - }), - 11 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'cline', - sessionId, - title: 'Fix remote Cline history', - cwd: '/home/ada/repo', - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - filePath: `${sessionDir}/${sessionId}.json` - }) - }) - - it('indexes and resumes Cline sessions from a Windows SSH host', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = '1786466194549_xrzrl' - const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}` - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - cwd: 'C:/repo/app' - }), - 10 - ) - provider.addFile( - `${sessionDir}/${sessionId}.messages.json`, - JSON.stringify({ - updated_at: '2026-08-11T16:38:00.000Z', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }] - }), - 11 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:win-box', - remoteHome: 'C:/Users/Ada', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'cline', - sessionId, - executionHostPlatform: 'win32', - filePath: `${sessionDir}/${sessionId}.json`, - resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""` - }) - }) - - it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/session_index.jsonl', - jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]), - 1 - ) - provider.addFile( - '/home/ada/.codex/sessions/2026/07/04/default.jsonl', - jsonLines([ - { - timestamp: '2026-07-04T01:00:00.000Z', - type: 'session_meta', - payload: { id: 'default-session', cwd: '/home/ada/repo' } - }, - { - timestamp: '2026-07-04T01:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Fallback default title' }] - } - } - ]), - 10 - ) - provider.addFile( - '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl', - jsonLines([ - { - timestamp: '2026-07-04T02:00:00.000Z', - type: 'session_meta', - payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' } - }, - { - timestamp: '2026-07-04T02:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Managed remote title' }] - } - } - ]), - 20 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - unlimited: true - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.title)).toEqual([ - 'Managed remote title', - 'Indexed remote title' - ]) - expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2) - expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true) - expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true) - expect( - result.sessions.find((session) => session.sessionId === 'default-session') - ).toMatchObject({ - codexHome: '/home/ada/.codex', - resumeCommand: - "cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'" - }) - expect( - result.sessions.find((session) => session.sessionId === 'runtime-session') - ).toMatchObject({ - codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home', - resumeCommand: - "cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'" - }) - }) - - it('collapses a bridged rollout present in both remote Codex homes to one row', async () => { - const provider = new MemoryRemoteProvider() - const rolloutName = 'rollout-2026-07-04T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' - const transcript = codexTranscript({ - sessionId: '019f0000-1111-7222-8333-444444444444', - title: 'Bridged both-homes session', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T10:00:00.000Z' - }) - // Same rollout name in both homes — the in-distro bridge/backfill hardlink. - provider.addFile(`/home/ada/.codex/sessions/2026/07/04/${rolloutName}`, transcript, 3_000) - provider.addFile( - `/home/ada/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${rolloutName}`, - transcript, - 3_000 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:build-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - // Remote lanes have not flipped to the real home: the managed runtime-home - // row stays canonical so resume keeps Orca-refreshed auth, as today. - expect(result.sessions[0]).toMatchObject({ - sessionId: '019f0000-1111-7222-8333-444444444444', - codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home' - }) - }) - - it('parses non-Codex transcripts through the same remote scanner', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/claude-session.jsonl', - jsonLines([ - { - sessionId: 'claude-session', - timestamp: '2026-07-04T04:00:00.000Z', - type: 'user', - message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] } - }, - { - sessionId: 'claude-session', - timestamp: '2026-07-04T04:00:01.000Z', - type: 'assistant', - message: { model: 'claude-opus-4', content: 'Sure.' } - } - ]), - 40 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'claude', - sessionId: 'claude-session', - title: 'Summarize the remote branch', - model: 'claude-opus-4', - filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl' - }) - }) - - it('discovers Prime Agent transcripts under the remote home sessions root', async () => { - const provider = new MemoryRemoteProvider() - const fixture = primeAgentFixture() - provider.addFile( - `/home/ada/.prime/agent/sessions/${fixture.fileName}`, - [...fixture.seedLines, ...fixture.appendLines].join('\n'), - 40 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'prime-agent', - title: 'prime-agent seed question', - model: 'inference/big-model', - filePath: `/home/ada/.prime/agent/sessions/${fixture.fileName}` - }) - }) - - it('parses only canonical Antigravity transcripts on SSH hosts', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' - const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs` - provider.addFile( - `${logsDir}/transcript.jsonl`, - jsonLines([ - { - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - created_at: '2026-07-15T11:39:10Z', - content: 'Fix remote Antigravity history' - }, - { - source: 'MODEL', - type: 'PLANNER_RESPONSE', - created_at: '2026-07-15T11:39:12Z', - content: 'Done' - } - ]), - 50 - ) - provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51) - provider.addFile( - `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`, - 'not a transcript', - 52 - ) - provider.addFile( - '/home/ada/.gemini/antigravity-cli/history.jsonl', - jsonLines([ - { - display: 'Fix remote Antigravity history', - timestamp: Date.parse('2026-07-15T11:39:10.100Z'), - workspace: '/home/ada/project' - } - ]), - 53 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - scopePaths: ['/home/ada/project'] - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - executionHostPlatform: 'linux', - agent: 'antigravity', - sessionId, - title: 'Fix remote Antigravity history', - cwd: '/home/ada/project', - messageCount: 2, - resumeCommand: `agy --conversation '${sessionId}'`, - filePath: `${logsDir}/transcript.jsonl` - }) - expect( - provider.readDirPaths.filter((path) => - path.startsWith('/home/ada/.gemini/antigravity-cli/brain') - ) - ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) - }) - - it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { - const provider = new MemoryRemoteProvider() - const brainDir = '/home/ada/.gemini/antigravity-cli/brain' - provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1) - const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl` - provider.addFile(deniedTranscript, 'unreadable', 2) - provider.failStat( - deniedTranscript, - new Error(`EACCES: permission denied, stat '${deniedTranscript}'`) - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ - agent: 'antigravity', - path: deniedTranscript, - message: expect.stringContaining('EACCES') - }) - ]) - }) - - it('reports non-missing fixed and recursive remote directory failures', async () => { - const provider = new MemoryRemoteProvider() - const brainDir = '/home/ada/.gemini/antigravity-cli/brain' - const claudeProjectDir = '/home/ada/.claude/projects/repo' - provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1) - provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`)) - provider.failReadDir( - claudeProjectDir, - new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`) - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - agent: 'antigravity', - kind: 'host', - path: brainDir, - message: expect.stringContaining('EACCES') - }), - expect.objectContaining({ - agent: 'claude', - kind: 'host', - path: claudeProjectDir, - message: expect.stringContaining('ECONNRESET') - }) - ]) - ) - expect(result.issues).toHaveLength(2) - }) - - it('keeps missing optional remote directories silent', async () => { - const provider = new MemoryRemoteProvider() - const brainDir = '/home/ada/.gemini/antigravity-cli/brain' - provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`)) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([]) - }) - - it('excludes Claude subagent transcripts from remote scans', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/claude-session.jsonl', - jsonLines([ - { - sessionId: 'claude-session', - timestamp: '2026-07-04T04:00:00.000Z', - type: 'user', - message: { content: [{ type: 'text', text: 'Spawn a Task' }] } - } - ]), - 40 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/claude-session/subagents/agent-abc123.jsonl', - jsonLines([ - { - sessionId: 'claude-session', - isSidechain: true, - agentId: 'abc123', - timestamp: '2026-07-04T04:01:00.000Z', - type: 'user', - message: { content: [{ type: 'text', text: 'Subagent task prompt' }] } - } - ]), - 41 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - // Only the parent session surfaces; the subagent transcript would carry - // the parent's sessionId and list as a phantom top-level row. - expect(result.sessions.map((session) => session.filePath)).toEqual([ - '/home/ada/.claude/projects/repo/claude-session.jsonl' - ]) - }) - - it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/lost-session.jsonl', - jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]), - 50 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl', - jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]), - 51 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl', - jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]), - 52 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - // Subagent transcripts must not surface as standalone sessions; they only - // contribute recoverable signal to their zero-turn parent. - expect(result.sessions).toHaveLength(1) - expect(result.sessions[0]).toMatchObject({ - agent: 'claude', - sessionId: 'lost-session', - messageCount: 0, - subagentTranscriptCount: 2, - filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl' - }) - }) - - it('counts remote subagent siblings for Claude sessions with real turns', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.claude/projects/repo/live-session.jsonl', - jsonLines([ - { - sessionId: 'live-session', - type: 'user', - message: { content: [{ type: 'text', text: 'Do the thing' }] } - } - ]), - 60 - ) - provider.addFile( - '/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl', - jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]), - 61 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(1) - // The walk listing supplies the count for every session (the row badge), - // not only zero-turn recoverable ones. - expect(result.sessions[0]).toMatchObject({ - sessionId: 'live-session', - messageCount: 1, - subagentTranscriptCount: 1 - }) - }) - - it('builds resume commands with the remote host platform', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - 'C:/Users/Ada/.codex/sessions/win.jsonl', - jsonLines([ - { - timestamp: '2026-07-04T03:00:00.000Z', - type: 'session_meta', - payload: { id: 'win-session', cwd: 'C:/repo/app' } - }, - { - timestamp: '2026-07-04T03:00:01.000Z', - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: 'Windows remote title' }] - } - } - ]), - 30 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:win-box', - remoteHome: 'C:/Users/Ada', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions[0]?.executionHostPlatform).toBe('win32') - expect(result.sessions[0]?.resumeCommand).toBe( - 'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""' - ) - }) - - it('loads Antigravity workspace history with Windows remote paths', async () => { - const provider = new MemoryRemoteProvider() - const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb' - provider.addFile( - `C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`, - jsonLines([ - { - source: 'USER_EXPLICIT', - type: 'USER_INPUT', - created_at: '2026-07-15T11:39:10.000Z', - content: 'Windows Antigravity title' - } - ]), - 30 - ) - provider.addFile( - 'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl', - jsonLines([ - { - display: 'Windows Antigravity title', - timestamp: Date.parse('2026-07-15T11:39:10.100Z'), - workspace: 'C:/repo/app' - } - ]), - 31 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:win-box', - remoteHome: 'C:/Users/Ada', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - - expect(result.issues).toEqual([]) - expect(result.sessions[0]).toMatchObject({ - agent: 'antigravity', - sessionId, - cwd: 'C:/repo/app', - executionHostPlatform: 'win32' - }) - }) - - it('continues past skipped candidates to fill the remote scan limit', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/sessions/worker.jsonl', - codexTranscript({ - sessionId: 'worker-session', - title: 'Internal worker', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T04:00:00.000Z', - threadSource: 'agent' - }), - 40 - ) - provider.addFile( - '/home/ada/.codex/sessions/user.jsonl', - codexTranscript({ - sessionId: 'user-session', - title: 'Visible user session', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T03:00:00.000Z' - }), - 30 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1 - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session']) - }) - - it('keeps scoped remote sessions even when they are older than the recency cap', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/sessions/other.jsonl', - codexTranscript({ - sessionId: 'other-session', - title: 'Other workspace', - cwd: '/home/ada/other', - timestamp: '2026-07-04T05:00:00.000Z' - }), - 50 - ) - provider.addFile( - '/home/ada/.codex/sessions/scoped.jsonl', - codexTranscript({ - sessionId: 'scoped-session', - title: 'Scoped workspace', - cwd: '/home/ada/repo/app', - timestamp: '2026-07-04T01:00:00.000Z' - }), - 10 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - 'other-session', - 'scoped-session' - ]) - }) - - it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => { - const provider = new MemoryRemoteProvider() - for (const [sessionId, mtimeMs, hour] of [ - ['other-newest', 50, '05'], - ['other-newer', 40, '04'] - ] as const) { - provider.addFile( - `/home/ada/.codex/sessions/${sessionId}.jsonl`, - codexTranscript({ - sessionId, - title: sessionId, - cwd: '/home/ada/other', - timestamp: `2026-07-04T${hour}:00:00.000Z` - }), - mtimeMs - ) - } - provider.addFile( - '/home/ada/.codex/sessions/scoped.jsonl', - codexTranscript({ - sessionId: 'scoped-session', - title: 'Scoped workspace', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T01:00:00.000Z' - }), - 10 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - 'other-newest', - 'scoped-session' - ]) - }) - - it('caps scoped backfill at the requested limit', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/sessions/other.jsonl', - codexTranscript({ - sessionId: 'other-session', - title: 'Other workspace', - cwd: '/home/ada/other', - timestamp: '2026-07-04T05:00:00.000Z' - }), - 50 - ) - for (const [sessionId, mtimeMs] of [ - ['newer-scoped', 30], - ['older-scoped', 20] - ] as const) { - provider.addFile( - `/home/ada/.codex/sessions/${sessionId}.jsonl`, - codexTranscript({ - sessionId, - title: sessionId, - cwd: '/home/ada/repo', - timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z` - }), - mtimeMs - ) - } - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - limit: 1, - scopePaths: ['/home/ada/repo'] - }) - - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - 'other-session', - 'newer-scoped' - ]) - }) -}) - -function codexTranscript(args: { - sessionId: string - title: string - cwd: string - timestamp: string - threadSource?: string -}): string { - return jsonLines([ - { - timestamp: args.timestamp, - type: 'session_meta', - payload: { - id: args.sessionId, - cwd: args.cwd, - ...(args.threadSource ? { thread_source: args.threadSource } : {}) - } - }, - { - timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: args.title }] - } - } - ]) -} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts deleted file mode 100644 index d240ee40e0c5..000000000000 --- a/src/main/ai-vault/remote-session-scanner.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { - candidateFileTime, - prioritizeAntigravityTranscriptCandidates -} from './antigravity-transcript-candidates' -import { readRemoteAntigravityIndex } from './antigravity-index-reader' -import { parseRemoteSessionTranscript } from './remote-session-transcript-read' -import { BinarySessionTranscriptError } from './remote-session-content-lines' -import type { - AiVaultListResult, - AiVaultScanIssue, - AiVaultSession -} from '../../shared/ai-vault-types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import type { ExecutionHostId } from '../../shared/execution-host' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { - codexRolloutHardlinkIdentity, - dedupeCodexRolloutFileAliases -} from './codex-session-root-dedup' -import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' -import { - parseRemoteSessionFileCached, - remoteSessionCandidateKey, - remoteSessionParseHostKey -} from './remote-session-parse-cache' -import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' -import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' -import { remoteSessionSources } from './remote-session-scanner-sources' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionFilesystemProvider -} from './remote-session-scanner-types' -import { sessionSortTime } from './session-scanner-accumulator' -import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' -import { errorMessage } from './session-scanner-values' -import { mapRemoteScanBatches } from './remote-session-scan-batching' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordSessionScanIssue } from './session-scan-issues' -import { canStopParsingSessions } from './session-scan-cutoff' -import { refreshCodexTitleFromIndex } from './session-scanner-codex-cached-title' -import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' -import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' -import { remoteOpenCodeSources } from './remote-session-scanner-opencode-source' - -const REMOTE_SCAN_CONCURRENCY = 8 -const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2 -// Remote scope membership is only known after a transcript is read, so the scope -// backfill carries its own ceiling on remote reads instead of borrowing the -// recency cap — under the cap, newer out-of-scope files ate the scope budget and -// silently dropped older in-scope sessions the scope contract guarantees. -const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000 - -export async function scanRemoteAiVaultSessions(args: { - provider: RemoteSessionFilesystemProvider - executionHostId: ExecutionHostId - remoteHome: string - kiroHomeDir?: string - hostPlatform: RemoteHostPlatform - includeAntigravityIdeSessions?: boolean - limit?: number - unlimited?: boolean - scopePaths?: readonly string[] - signal?: AbortSignal -}): Promise { - throwIfAiVaultScanCancelled(args.signal) - const limit = aiVaultScanLimit(args) - const issues: AiVaultScanIssue[] = [] - // One ceiling for the whole scan: discovery walks, stats and transcript reads - // all queue behind it instead of multiplying into a nested fan-out. - const provider = limitRemoteScanFilesystemConcurrency(args.provider) - const context: RemoteScannerContext = { - provider, - executionHostId: args.executionHostId, - hostPlatform: args.hostPlatform, - signal: args.signal, - titleCaches: new Map(), - antigravityWorkspaceResolver: createAntigravityWorkspaceResolver((path) => - readRemoteAntigravityIndex(provider, path, args.signal) - ) - } - const discoveredCandidates = dedupeCodexRolloutFileAliases( - ( - await mapRemoteScanBatches( - [ - ...remoteSessionSources( - args.remoteHome, - args.hostPlatform, - args.includeAntigravityIdeSessions, - args.kiroHomeDir - ), - ...remoteOpenCodeSources( - provider.openCode, - limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER + - (args.scopePaths?.length ? REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT + 1 : 0) - ) - ], - REMOTE_SCAN_CONCURRENCY, - (source) => discoverRemoteSourceCandidates({ source, context, issues }), - args.signal - ) - ) - .flat() - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), - { - isCodex: (candidate) => candidate.source.agent === 'codex', - getFilePath: (candidate) => candidate.file.path, - getCodexHome: (candidate) => candidate.source.codexHome ?? null, - getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) - } - ) - - const candidates = prioritizeAntigravityTranscriptCandidates( - discoveredCandidates, - (candidate) => candidate.source.agent === 'antigravity' - ) - const parsed = await parseRemoteSessionCandidates({ - candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER), - context, - issues, - limit - }) - const parsedSessions = dedupeScannedSessions(parsed.sessions) - const cappedSessions = parsedSessions - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) - const parsedScopeSessions = parsedSessions.filter((session) => - isRemoteSessionInScope(session, scopePaths) - ) - const extraScopeSessions = await scanRemoteInScopeSessions({ - candidates, - context, - issues, - scopePaths, - limit, - alreadyParsedCandidateKeys: parsed.parsedCandidateKeys - }) - const scopeSessions = dedupeScannedSessions([...parsedScopeSessions, ...extraScopeSessions]) - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - - return { - sessions: mergeRemoteSessions(cappedSessions, scopeSessions), - issues, - scannedAt: new Date().toISOString() - } -} - -async function parseRemoteSessionCandidates(args: { - candidates: readonly RemoteSessionCandidate[] - context: RemoteScannerContext - issues: AiVaultScanIssue[] - limit: number -}): Promise<{ sessions: AiVaultSession[]; parsedCandidateKeys: Set }> { - const sessions = new ScannedSessionCollection() - const parsedCandidateKeys = new Set() - let index = 0 - - while (index < args.candidates.length) { - if ( - canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file)) - ) { - break - } - - const remaining = args.candidates.length - index - const needed = Math.max(args.limit - sessions.size, 1) - const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining) - const batch = args.candidates.slice(index, index + batchSize) - for (const candidate of batch) { - parsedCandidateKeys.add(remoteSessionCandidateKey(candidate)) - } - throwIfAiVaultScanCancelled(args.context.signal) - const results = await Promise.all( - batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) - ) - for (const session of results) { - if (session) { - sessions.add(session) - } - } - index += batchSize - await yieldToEventLoop() - } - - // The loop can terminate on the yield after its final batch, so re-check - // rather than letting a cancelled scan return a partial parse as a success. - throwIfAiVaultScanCancelled(args.context.signal) - return { sessions: [...sessions.values()], parsedCandidateKeys } -} - -async function scanRemoteInScopeSessions(args: { - candidates: readonly RemoteSessionCandidate[] - context: RemoteScannerContext - issues: AiVaultScanIssue[] - scopePaths: readonly string[] - limit: number - alreadyParsedCandidateKeys: ReadonlySet -}): Promise { - if (args.scopePaths.length === 0) { - return [] - } - - const candidates = args.candidates.filter( - (candidate) => !args.alreadyParsedCandidateKeys.has(remoteSessionCandidateKey(candidate)) - ) - const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT) - const sessions: AiVaultSession[] = [] - let index = 0 - - // Keep reading newest-first until the scope has its requested number of - // sessions; out-of-scope candidates no longer end the search. - while (index < bound && sessions.length < args.limit) { - const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound) - const results = await mapRemoteScanBatches( - candidates.slice(index, batchEnd), - REMOTE_SCAN_CONCURRENCY, - (candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues), - args.context.signal - ) - sessions.push( - ...results.filter( - (session): session is AiVaultSession => - isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) - ) - ) - index = batchEnd - } - - if (index < candidates.length && sessions.length < args.limit) { - recordSessionScanIssue(args.issues, { - executionHostId: args.context.executionHostId, - agent: 'codex', - kind: 'scope', - path: 'Agent Session History scan', - message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.` - }) - } - - return sessions -} - -async function parseRemoteSessionCandidate( - candidate: RemoteSessionCandidate, - context: RemoteScannerContext, - issues: AiVaultScanIssue[] -): Promise { - try { - throwIfAiVaultScanCancelled(context.signal) - // The read is inside the cached parse: an unchanged transcript must not be - // pulled off the remote disk at all, which is the whole cost of #13753. - const session = await parseRemoteSessionFileCached({ - candidate, - hostKey: remoteSessionParseHostKey(context), - parse: () => parseRemoteSessionTranscript(candidate, context), - refreshReusedSession: reusedCodexTitleRefresh(candidate, context) - }) - throwIfAiVaultScanCancelled(context.signal) - // Mirror the local rule: every session carries its sibling subagent - // transcript count (row badge; recoverable signal at zero turns). The - // walk listing supplies it — the parser can't readdir a remote disk. - const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0 - if (session && subagentTranscriptCount > 0) { - return { ...session, subagentTranscriptCount } - } - return session - } catch (err) { - throwIfAiVaultScanCancelled(context.signal) - if (err instanceof BinarySessionTranscriptError) { - return null - } - recordSessionScanIssue(issues, { - executionHostId: context.executionHostId, - agent: candidate.source.agent, - path: candidate.file.path, - message: errorMessage(err) - }) - return null - } -} - -// Codex thread names live in `/session_index.jsonl`, not the -// rollout, and are written after it — so a transcript-keyed cache hit would -// pin the fallback title forever. Local counterpart: -// session-scanner-parse-cache.ts's reuse path. -function reusedCodexTitleRefresh( - candidate: RemoteSessionCandidate, - context: RemoteScannerContext -): ((session: AiVaultSession) => Promise) | undefined { - const codexHome = candidate.source.agent === 'codex' ? candidate.source.codexHome : undefined - if (!codexHome) { - return undefined - } - const readIndexedTitle = remoteCodexIndexedTitleReader(codexHome, context) - return (session) => refreshCodexTitleFromIndex(session, readIndexedTitle) -} - -function mergeRemoteSessions( - cappedSessions: AiVaultSession[], - scopeSessions: AiVaultSession[] -): AiVaultSession[] { - if (scopeSessions.length === 0) { - return cappedSessions - } - const byId = new Map() - for (const session of cappedSessions) { - byId.set(session.id, session) - } - for (const session of scopeSessions) { - byId.set(session.id, session) - } - return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) -} - -function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean { - const cwd = session.cwd - return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) -} - -function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { - return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) -} - -function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { - return Boolean(session) -} diff --git a/src/main/ai-vault/remote-session-sidecar-observation.test.ts b/src/main/ai-vault/remote-session-sidecar-observation.test.ts deleted file mode 100644 index 92ff41775ec0..000000000000 --- a/src/main/ai-vault/remote-session-sidecar-observation.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' -import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache' - -describe('remote sidecar observations', () => { - const sessionId = '1786466194549_sidecar' - const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` - const messagesPath = `${sessionDir}/${sessionId}.messages.json` - - function addCline(provider: MemoryRemoteProvider, firstPrompt: string): void { - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - version: 1, - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - cwd: '/home/ada/repo' - }), - 10 - ) - provider.addFile( - messagesPath, - JSON.stringify({ - version: 1, - updated_at: '2026-08-11T16:38:00.000Z', - sessionId, - messages: [{ role: 'user', content: [{ type: 'text', text: firstPrompt }] }] - }), - 11 - ) - } - - const scan = (provider: MemoryRemoteProvider): ReturnType => - scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - it('re-parses when a messages-file stat fails rather than serving the cached row', async () => { - resetRemoteSessionParseCacheForTests() - const provider = new MemoryRemoteProvider() - addCline(provider, 'first prompt') - expect((await scan(provider)).sessions[0]).toMatchObject({ title: 'first prompt' }) - - // The sidecar changed underneath, and its stat now fails for a reason that - // is not "missing": nothing about it may be assumed, so the row is re-read. - addCline(provider, 'second prompt') - provider.failStat( - messagesPath, - Object.assign(new Error('permission denied'), { code: 'EACCES' }) - ) - - const refused = await scan(provider) - - expect(refused.sessions[0]).toMatchObject({ title: 'second prompt' }) - expect(refused.issues.map((issue) => issue.path)).toContain(messagesPath) - }) - - it('treats a genuinely missing messages file as no sidecar, not as unknown', async () => { - resetRemoteSessionParseCacheForTests() - const provider = new MemoryRemoteProvider() - provider.addFile( - `${sessionDir}/${sessionId}.json`, - JSON.stringify({ - version: 1, - session_id: sessionId, - started_at: '2026-08-11T16:36:34.551Z', - cwd: '/home/ada/repo' - }), - 10 - ) - - const first = await scan(provider) - const second = await scan(provider) - - expect(first.issues).toEqual([]) - expect(second.issues).toEqual([]) - expect(second.sessions[0]?.sessionId).toBe(sessionId) - }) -}) diff --git a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts deleted file mode 100644 index 2b8e95ea2a44..000000000000 --- a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { streamedSessionContentLines } from './remote-session-content-lines' -import { readStreamedSessionDocument } from './session-document-stream' -import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' - -describe('stream lifetime and retained document work', () => { - it('aborts a newline-free record at the byte ceiling and closes the source', async () => { - let closed = false - let reads = 0 - async function* bytes() { - const chunk = Buffer.alloc(1024 * 1024, 'x') - try { - for (; reads < 100;) { - reads++ - yield chunk - } - } finally { - closed = true - } - } - const lines = streamedSessionContentLines(bytes()) - await expect(lines.next()).rejects.toThrow('record exceeds 10485760 byte limit') - expect(reads).toBe(11) - expect(closed).toBe(true) - }) - - it('releases the source when a line consumer finishes early', async () => { - let closed = false - async function* bytes() { - try { - yield Buffer.from('one\ntwo\n') - yield Buffer.from('three\n') - } finally { - closed = true - } - } - for await (const line of streamedSessionContentLines(bytes())) { - expect(line).toBe('one') - break - } - await vi.waitFor(() => expect(closed).toBe(true)) - }) - it('propagates disk failure and closes the source', async () => { - let closed = false - async function* bytes() { - try { - yield Buffer.from('one\n') - throw new Error('disk read failed') - } finally { - closed = true - } - } - await expect( - (async () => { - for await (const _ of streamedSessionContentLines(bytes())) { - /* consume */ - } - })() - ).rejects.toThrow('disk read failed') - expect(closed).toBe(true) - }) - it('cancellation discards a document fold and releases its source', async () => { - const controller = new AbortController() - let closed = false - async function* bytes() { - try { - yield Buffer.from('{"messages":[{"role":"user"}') - controller.abort() - yield Buffer.from(']}') - } finally { - closed = true - } - } - await expect( - readStreamedSessionDocument({ - bytes: bytes(), - arrayKey: 'messages', - fields: [], - create: () => ({ count: 0 }), - consume: (state) => { - state.count++ - }, - signal: controller.signal - }) - ).rejects.toThrow() - expect(closed).toBe(true) - }) - it('holds one filesystem slot for the stream lifetime and releases it on return', async () => { - let entered = 0 - async function* bytes() { - entered++ - yield Buffer.from('a') - yield Buffer.from('b') - } - const provider = limitRemoteScanFilesystemConcurrency( - { - readDir: async () => [], - readFile: async () => ({ content: '', isBinary: false }), - stat: async () => ({ size: 0, type: 'file', mtime: 0 }), - readTranscriptBytes: bytes - }, - 1 - ) - const first = provider.readTranscriptBytes!('/one')[Symbol.asyncIterator](), - second = provider.readTranscriptBytes!('/two')[Symbol.asyncIterator]() - await first.next() - const pending = second.next() - await Promise.resolve() - expect(entered).toBe(1) - await first.return!() - await pending - expect(entered).toBe(2) - await second.return!() - }) -}) diff --git a/src/main/ai-vault/remote-session-transcript-read.ts b/src/main/ai-vault/remote-session-transcript-read.ts deleted file mode 100644 index bc9d4a595323..000000000000 --- a/src/main/ai-vault/remote-session-transcript-read.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { streamedSessionContentLines } from './remote-session-content-lines' -import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import type { RemoteSessionCandidate, RemoteScannerContext } from './remote-session-scanner-types' -import type { AiVaultSession } from '../../shared/ai-vault-types' - -const LEGACY_SESSION_TEXT_LIMIT_BYTES = 10 * 1024 * 1024 - -export async function parseRemoteSessionTranscript( - candidate: RemoteSessionCandidate, - context: RemoteScannerContext -): Promise { - if (candidate.source.parseCandidate) { - return candidate.source.parseCandidate(candidate.file, context) - } - const sidecar = candidate.file.sidecar - const exceedsWholeReadLimit = - (candidate.file.sizeBytes ?? 0) > LEGACY_SESSION_TEXT_LIMIT_BYTES || - (typeof sidecar === 'object' && sidecar.sizeBytes > LEGACY_SESSION_TEXT_LIMIT_BYTES) - if ( - exceedsWholeReadLimit && - candidate.source.parseDocument && - !candidate.file.path.endsWith('.jsonl') && - context.provider.readTranscriptBytes - ) { - return candidate.source.parseDocument( - candidate.file, - context.provider.readTranscriptBytes(candidate.file.path, context.signal), - context - ) - } - if ( - exceedsWholeReadLimit && - candidate.file.path.endsWith('.jsonl') && - candidate.source.parseLines && - context.provider.readTranscriptBytes - ) { - return candidate.source.parseLines( - candidate.file, - streamedSessionContentLines( - context.provider.readTranscriptBytes(candidate.file.path, context.signal), - context.signal - ), - context - ) - } - const read = await context.provider.readFile(candidate.file.path) - throwIfAiVaultScanCancelled(context.signal) - if (read.isBinary) { - return null - } - return await candidate.source.parse(candidate.file, read.content, context) -} diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 01fd56c23fc2..a891a8000a9c 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -116,6 +116,33 @@ describe('runtime AI Vault session scanner', () => { ]) }) + it('keeps full old-server replies and accepts only a marker matching the sent fingerprint', async () => { + const fingerprint = 'a'.repeat(64) + const old = await scanRuntimeAiVaultSessions('/user-data', 'env-1', { + afterFingerprint: fingerprint + }) + expect(old.sessions).toHaveLength(1) + expect(old.unchanged).toBeUndefined() + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { + sessions: [], + issues: [], + scannedAt: 'fresh', + fingerprint, + unchanged: true + } + }) + expect( + await scanRuntimeAiVaultSessions('/user-data', 'env-1', { afterFingerprint: fingerprint }) + ).toMatchObject({ fingerprint, unchanged: true, scannedAt: 'fresh' }) + const invalid = await scanRuntimeAiVaultSessions('/user-data', 'env-1', { + afterFingerprint: 'b'.repeat(64) + }) + expect(invalid.unchanged).toBeUndefined() + expect(invalid.issues[0]?.kind).toBe('host') + }) + it('prepares a resume on the transcript-owning runtime', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 1640f2f4b30c..0ca60f8433b6 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -60,6 +60,7 @@ export async function scanRuntimeAiVaultSessions( limit: args.limit, unlimited: args.unlimited, force: args.force, + ...(args.afterFingerprint !== undefined ? { afterFingerprint: args.afterFingerprint } : {}), // Why: cap here so the set of scanned paths is explicit on this side — // the RPC schema CLAMPS to the same bound anyway (older hosts had no // cap). Dropped paths only lose the older-than-recency-cap guarantee, @@ -71,10 +72,14 @@ export async function scanRuntimeAiVaultSessions( ) if (response.ok === true) { try { - const result = withRuntimeExecutionHost( - parseAiVaultListResult(response.result), - executionHostId - ) + const parsed = parseAiVaultListResult(response.result) + if (parsed.unchanged) { + if (parsed.fingerprint !== args.afterFingerprint) { + throw new Error('Unchanged response did not match the requested fingerprint') + } + return parsed + } + const result = withRuntimeExecutionHost(parsed, executionHostId) if (!args.scopePaths || args.scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT) { return result } @@ -159,7 +164,8 @@ function withRuntimeExecutionHost( return { sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)), issues: result.issues.map((issue) => ({ ...issue, executionHostId })), - scannedAt: result.scannedAt + scannedAt: result.scannedAt, + ...(result.fingerprint ? { fingerprint: result.fingerprint } : {}) } } diff --git a/src/main/ai-vault/session-list-fingerprint.test.ts b/src/main/ai-vault/session-list-fingerprint.test.ts new file mode 100644 index 000000000000..6f01a18b9fa7 --- /dev/null +++ b/src/main/ai-vault/session-list-fingerprint.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { conditionalAiVaultListResult } from './session-list-fingerprint' + +const result: AiVaultListResult = { sessions: [], issues: [], scannedAt: 'first' } + +describe('conditional session content replies', () => { + it('leaves legacy calls exactly unchanged and refreshes only the scan stamp', () => { + expect(conditionalAiVaultListResult(result, undefined)).toBe(result) + const first = conditionalAiVaultListResult(result, null) + expect( + conditionalAiVaultListResult({ ...result, scannedAt: 'second' }, first.fingerprint) + ).toEqual({ + sessions: [], + issues: [], + scannedAt: 'second', + fingerprint: first.fingerprint, + unchanged: true + }) + }) + + it('includes all content metadata and does not mistake force for an unchanged scan', () => { + const first = conditionalAiVaultListResult(result, null) + for (const changed of [ + { ...result, issues: [{ agent: 'codex' as const, path: 'source', message: 'warning' }] }, + { ...result, cancelled: true as const }, + { ...result, futureMetadata: 'new field' } + ]) { + const reply = conditionalAiVaultListResult(changed, first.fingerprint) + expect(reply.unchanged).toBeUndefined() + expect(reply.fingerprint).not.toBe(first.fingerprint) + } + expect(conditionalAiVaultListResult(result, first.fingerprint, true).unchanged).toBeUndefined() + }) +}) diff --git a/src/main/ai-vault/session-list-fingerprint.ts b/src/main/ai-vault/session-list-fingerprint.ts new file mode 100644 index 000000000000..6d716a837039 --- /dev/null +++ b/src/main/ai-vault/session-list-fingerprint.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto' +import type { AiVaultListResult } from '../../shared/ai-vault-types' + +export function conditionalAiVaultListResult( + result: AiVaultListResult, + afterFingerprint: string | null | undefined, + force = false +): AiVaultListResult { + if (afterFingerprint === undefined) { + return result + } + const fingerprint = createHash('sha256') + .update(JSON.stringify({ ...result, scannedAt: undefined })) + .digest('hex') + return !force && afterFingerprint === fingerprint + ? { sessions: [], issues: [], scannedAt: result.scannedAt, fingerprint, unchanged: true } + : { ...result, fingerprint } +} diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 4ba72fdd43be..5b005eb60bf5 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -97,11 +97,23 @@ const aiVaultScanIssueSchema = z.object({ message: z.string() }) -const aiVaultListResultEnvelopeSchema = z.object({ - sessions: z.array(z.unknown()), - issues: z.array(z.unknown()), - scannedAt: z.string() -}) +const aiVaultListResultEnvelopeSchema = z + .object({ + sessions: z.array(z.unknown()), + issues: z.array(z.unknown()), + scannedAt: z.string(), + fingerprint: z + .string() + .regex(/^[0-9a-f]{64}$/) + .optional(), + unchanged: z.literal(true).optional() + }) + .refine( + (value) => + !value.unchanged || + (value.fingerprint !== undefined && value.sessions.length === 0 && value.issues.length === 0), + 'Invalid unchanged response' + ) export function parseAiVaultListResult(value: unknown): AiVaultListResult { const envelope = aiVaultListResultEnvelopeSchema.safeParse(value) @@ -151,5 +163,11 @@ export function parseAiVaultListResult(value: unknown): AiVaultListResult { message: `Skipped ${invalidCount} invalid Agent Session History result ${invalidCount === 1 ? 'entry' : 'entries'}.` }) } - return { sessions, issues, scannedAt: envelope.data.scannedAt } + return { + sessions, + issues, + scannedAt: envelope.data.scannedAt, + ...(envelope.data.fingerprint ? { fingerprint: envelope.data.fingerprint } : {}), + ...(envelope.data.unchanged ? { unchanged: true as const } : {}) + } } diff --git a/src/main/ai-vault/session-newest-files.ts b/src/main/ai-vault/session-newest-files.ts index c315c26dfad6..c5fc252f338a 100644 --- a/src/main/ai-vault/session-newest-files.ts +++ b/src/main/ai-vault/session-newest-files.ts @@ -16,11 +16,7 @@ export class SessionNewestFiles { this.files.push(file) return } - if (this.limit <= 0) { - return - } - const last = this.files.at(-1) - if (this.files.length >= this.limit && last && file.mtimeMs <= last.mtimeMs) { + if (!this.wouldRetain(file.mtimeMs)) { return } let low = 0 @@ -39,6 +35,14 @@ export class SessionNewestFiles { } } + wouldRetain(mtimeMs: number): boolean { + if (this.limit <= 0) { + return false + } + const last = this.files.at(-1) + return !(this.files.length >= this.limit && last && mtimeMs <= last.mtimeMs) + } + get size(): number { return this.files.length } diff --git a/src/main/ai-vault/session-scanner-cutoff.test.ts b/src/main/ai-vault/session-scanner-cutoff.test.ts index 76488ddabbe1..acf102c6c48c 100644 --- a/src/main/ai-vault/session-scanner-cutoff.test.ts +++ b/src/main/ai-vault/session-scanner-cutoff.test.ts @@ -2,9 +2,6 @@ import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -15,63 +12,46 @@ afterEach(async () => { }) describe('session scanner cutoff', () => { - it.each(['native', 'remote'] as const)( - '%s does not sort timestamps at every post-limit candidate', - async (host) => { - const count = 128 - const limit = count / 2 - const provider = new MemoryRemoteProvider() - const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - await mkdir(roots.codexSessionsDir, { recursive: true }) - for (let index = 0; index < count; index++) { - const name = `rollout-session-${index}.jsonl` - const content = jsonLines([ - { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, - { - type: 'event_msg', - timestamp: new Date(index).toISOString(), - payload: { type: 'user_message', message: 'Check this session' } - } - ]) - const mtime = 10_000 - index - if (host === 'native') { - const filePath = join(roots.codexSessionsDir, name) - await writeFile(filePath, content) - await utimes(filePath, new Date(mtime), new Date(mtime)) - } else { - provider.addFile(`/home/ada/.codex/sessions/${name}`, content, mtime) + it('does not sort timestamps at every post-limit candidate', async () => { + const count = 128 + const limit = count / 2 + const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.codexSessionsDir, { recursive: true }) + for (let index = 0; index < count; index++) { + const name = `rollout-session-${index}.jsonl` + const content = jsonLines([ + { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, + { + type: 'event_msg', + timestamp: new Date(index).toISOString(), + payload: { type: 'user_message', message: 'Check this session' } } + ]) + const mtime = 10_000 - index + const filePath = join(roots.codexSessionsDir, name) + await writeFile(filePath, content) + await utimes(filePath, new Date(mtime), new Date(mtime)) + } + let numericSorts = 0 + const originalSort = Array.prototype.sort + vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) { + if (typeof this[0] === 'number') { + numericSorts++ } - let numericSorts = 0 - const originalSort = Array.prototype.sort - vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) { - if (typeof this[0] === 'number') { - numericSorts++ - } - return originalSort.call(this, compare) - }) - const scan = () => - host === 'native' - ? scanAiVaultSessions({ ...roots, limit }) - : scanRemoteAiVaultSessions({ - provider, - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - executionHostId: 'ssh:scan-cutoff', - limit - }) + return originalSort.call(this, compare) + }) + const scan = () => scanAiVaultSessions({ ...roots, limit }) - for (let pass = 0; pass < 2; pass++) { - numericSorts = 0 - const result = await scan() - expect(result.issues).toEqual([]) - expect(result.sessions.map((row) => row.sessionId)).toEqual( - Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`) - ) - expect(numericSorts).toBe(0) - } + for (let pass = 0; pass < 2; pass++) { + numericSorts = 0 + const result = await scan() + expect(result.issues).toEqual([]) + expect(result.sessions.map((row) => row.sessionId)).toEqual( + Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`) + ) + expect(numericSorts).toBe(0) } - ) + }) }) diff --git a/src/main/ai-vault/session-scanner-dedup-batches.test.ts b/src/main/ai-vault/session-scanner-dedup-batches.test.ts index 7cf79e3ba258..facc28b9c59d 100644 --- a/src/main/ai-vault/session-scanner-dedup-batches.test.ts +++ b/src/main/ai-vault/session-scanner-dedup-batches.test.ts @@ -2,9 +2,6 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -15,96 +12,37 @@ afterEach(async () => { }) describe('session scan batch deduplication', () => { - it.each(['native', 'remote'] as const)( - '%s does not rederive every retained rollout alias after each batch', - async (host) => { - const count = 128 - const provider = new MemoryRemoteProvider() - const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - await mkdir(roots.codexSessionsDir, { recursive: true }) - for (let index = 0; index < count; index++) { - const name = `rollout-session-${index}.jsonl` - const content = jsonLines([ - { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, - { type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } } - ]) - if (host === 'native') { - await writeFile(join(roots.codexSessionsDir, name), content) - } else { - provider.addFile(`/home/ada/.codex/sessions/${name}`, content, count - index) - } - } - let aliasChecks = 0 - const originalTest = RegExp.prototype.test - vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) { - if (this.source === '^rollout-.+\\.jsonl$') { - aliasChecks++ - } - return originalTest.call(this, value) - }) - const scan = () => - host === 'native' - ? scanAiVaultSessions({ ...roots, unlimited: true }) - : scanRemoteAiVaultSessions({ - provider, - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - executionHostId: 'ssh:dedup-batches', - unlimited: true - }) - - for (let pass = 0; pass < 2; pass++) { - aliasChecks = 0 - const result = await scan() - expect(result.issues).toEqual([]) - expect(result.sessions).toHaveLength(count) - expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count) - expect(aliasChecks).toBeLessThanOrEqual(count * 8) - } - } - ) - - it('replaces aliases across remote batches without consuming the unique-session budget', async () => { - const provider = new MemoryRemoteProvider() - const managedHome = '/home/ada/.local/share/orca/codex-runtime-home/home' - const content = (id: string) => - jsonLines([ - { type: 'session_meta', payload: { id, cwd: '/repo/folder' } }, - { type: 'event_msg', payload: { type: 'user_message', message: 'Session' } } + it('does not rederive every retained rollout alias after each batch', async () => { + const count = 128 + const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.codexSessionsDir, { recursive: true }) + for (let index = 0; index < count; index++) { + const name = `rollout-session-${index}.jsonl` + const content = jsonLines([ + { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, + { type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } } ]) - for (let index = 0; index < 8; index++) { - const name = `rollout-${index}.jsonl` - provider.addFile(`/home/ada/.codex/sessions/${name}`, content(`${index}`), 1000 - index) - provider.addFile(`${managedHome}/sessions/${name}`, content(`${index}`), 500 - index) + await writeFile(join(roots.codexSessionsDir, name), content) } - provider.addFile('/home/ada/.codex/sessions/rollout-unique.jsonl', content('unique'), 100) - const tooOld = '/home/ada/.codex/sessions/rollout-too-old.jsonl' - provider.addFile(tooOld, content('too-old'), 50) - const reads = vi.spyOn(provider, 'readFile') - const result = await scanRemoteAiVaultSessions({ - provider, - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - executionHostId: 'ssh:batch-replacements', - limit: 9 + let aliasChecks = 0 + const originalTest = RegExp.prototype.test + vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) { + if (this.source === '^rollout-.+\\.jsonl$') { + aliasChecks++ + } + return originalTest.call(this, value) }) - expect(result.issues).toEqual([]) - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - 'unique' - ]) - expect(result.sessions.slice(0, 8).every((session) => session.codexHome === managedHome)).toBe( - true - ) - expect(reads).not.toHaveBeenCalledWith(tooOld) + const scan = () => scanAiVaultSessions({ ...roots, unlimited: true }) + + for (let pass = 0; pass < 2; pass++) { + aliasChecks = 0 + const result = await scan() + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(count) + expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count) + expect(aliasChecks).toBeLessThanOrEqual(count * 8) + } }) }) diff --git a/src/main/ai-vault/session-scanner-devin-dedup.test.ts b/src/main/ai-vault/session-scanner-devin-dedup.test.ts index 0af43651a395..2546937411e5 100644 --- a/src/main/ai-vault/session-scanner-devin-dedup.test.ts +++ b/src/main/ai-vault/session-scanner-devin-dedup.test.ts @@ -2,9 +2,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' import { isolatedScanRoots } from './session-scanner-test-fixtures' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { dedupeScannedSessions, ScannedSessionCollection } from './session-root-dedup' @@ -45,32 +42,6 @@ it('lists a session in both default directories once and still fills the scan li expect(unlimited.sessions.map((session) => session.sessionId)).toEqual(['same', 'other']) }) -it.each(['win32-x64', 'linux-x64'] as const)( - 'deduplicates both remote directories on %s', - async (platform) => { - const provider = new MemoryRemoteProvider() - const windows = platform === 'win32-x64' - const home = windows ? 'C:/Users/ada' : '/home/ada' - const cliDir = `${home}/${windows ? 'AppData/Roaming' : '.local/share'}/devin/cli` - provider.addFile(`${cliDir}/transcripts/same.json`, transcript('same'), 10) - provider.addFile(`${cliDir}/agent_logs/devin-same.json`, transcript('same'), 20) - provider.addFile( - `${cliDir}/transcripts/other.json`, - transcript('other', '2026-09-18T00:00:00Z'), - 5 - ) - const result = await scanRemoteAiVaultSessions({ - provider, - remoteHome: home, - executionHostId: 'ssh:devin', - hostPlatform: getRemoteHostPlatform(platform), - limit: 2 - }) - expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other']) - expect(result.sessions[0].filePath).toBe(`${cliDir}/agent_logs/devin-same.json`) - } -) - function session(path: string) { const parsed = parseDevinSessionContent( { path, mtimeMs: 100, sizeBytes: 1, modifiedAt: new Date(100).toISOString() }, diff --git a/src/main/ai-vault/session-scanner-discovery-pipeline.test.ts b/src/main/ai-vault/session-scanner-discovery-pipeline.test.ts new file mode 100644 index 000000000000..4ec442268202 --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery-pipeline.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readdir: fsMocks.readdir, + stat: fsMocks.stat +})) + +import { discoverFiles } from './session-scanner-discovery' + +const fs = await vi.importActual('node:fs/promises') +let root: string + +beforeEach(async () => { + fsMocks.readdir.mockReset().mockImplementation(fs.readdir) + fsMocks.stat.mockReset().mockImplementation(fs.stat) + root = await fs.mkdtemp(join(tmpdir(), 'orca-fresh-discovery-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(root, { recursive: true, force: true }) +}) + +async function createFiles(directory: string, count: number): Promise { + await fs.mkdir(directory, { recursive: true }) + await Promise.all( + Array.from({ length: count }, (_, index) => + fs.writeFile(join(directory, `${index.toString().padStart(3, '0')}.jsonl`), '{}\n') + ) + ) + return (await fs.readdir(directory)).map((name) => join(directory, name)) +} + +function scan(scanRoot = root, issues: AiVaultScanIssue[] = [], limit = 100) { + return discoverFiles({ + rootDir: scanRoot, + issues, + limit, + agent: 'claude', + extensions: ['.jsonl'] + }) +} + +describe('fresh discovery pipeline', () => { + it('formats timestamps only for retained files while still reading every candidate', async () => { + await createFiles(root, 35) + fsMocks.stat.mockImplementation(async (path: string) => + Object.assign(await fs.stat(path), { mtimeMs: 1_000 }) + ) + const format = vi.spyOn(Date.prototype, 'toISOString') + expect((await scan(root, [], 3)).files).toHaveLength(3) + expect(format).toHaveBeenCalledTimes(3) + expect(fsMocks.stat).toHaveBeenCalledTimes(35) + format.mockClear() + expect((await scan(root, [], 0)).files).toEqual([]) + expect(format).not.toHaveBeenCalled() + expect(fsMocks.stat).toHaveBeenCalledTimes(70) + expect((await scan(root, [], Infinity)).files).toHaveLength(35) + expect(format).toHaveBeenCalledTimes(35) + }) + + it('reports invalid timestamps even for empty requests, after unavailable sidecar issues', async () => { + const [file] = await createFiles(root, 1) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path.endsWith('.meta')) { + throw Object.assign(new Error('metadata refused'), { code: 'EACCES' }) + } + return Object.assign(await fs.stat(path), { mtimeMs: Infinity }) + }) + const issues: AiVaultScanIssue[] = [] + const discovery = await discoverFiles({ + rootDir: root, + issues, + limit: 0, + agent: 'claude', + extensions: ['.jsonl'], + contentDependencyPath: (path) => `${path}.meta` + }) + expect(discovery.files).toEqual([]) + expect(issues).toEqual([ + { + agent: 'claude', + path: `${file}.meta`, + message: 'Session metadata could not be read this scan.' + }, + { agent: 'claude', path: file, message: expect.stringMatching(/Invalid (?:time value|Date)/) } + ]) + }) + + it('preserves traversal ties when later metadata completes first, including the partial batch', async () => { + const paths = await createFiles(root, 131) + let releaseFirst = () => {} + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve + }) + let reachedLater = () => {} + const laterRead = new Promise((resolve) => { + reachedLater = resolve + }) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path === paths[0]) { + await firstBlocked + } + if (path === paths[15]) { + reachedLater() + } + return Object.assign(await fs.stat(path), { mtimeMs: 1_000 }) + }) + + const discovery = scan(root, [], 130) + try { + await laterRead + } finally { + releaseFirst() + } + expect((await discovery).files.map((file) => file.path)).toEqual(paths.slice(0, 130)) + expect(fsMocks.stat).toHaveBeenCalledTimes(paths.length) + }) + + it('bounds directory and metadata reads across simultaneous roots', async () => { + const roots = Array.from({ length: 8 }, (_, index) => join(root, `root-${index}`)) + await Promise.all(roots.map((directory) => createFiles(directory, 70))) + let active = 0 + let peak = 0 + async function tracked(read: () => Promise): Promise { + active++ + peak = Math.max(peak, active) + try { + await new Promise((resolve) => setImmediate(resolve)) + return await read() + } finally { + active-- + } + } + fsMocks.readdir.mockImplementation((path: string, options: { withFileTypes: true }) => + tracked(() => fs.readdir(path, options)) + ) + fsMocks.stat.mockImplementation((path: string) => tracked(() => fs.stat(path))) + + const results = await Promise.all(roots.map((directory) => scan(directory, [], 3))) + expect(results.every((result) => result.files.length === 3)).toBe(true) + expect(peak).toBeGreaterThan(1) + expect(peak).toBeLessThanOrEqual(8) + expect(active).toBe(0) + }) + + it('sees file, sidecar, and nested membership changes on the next scan without notifications', async () => { + const [file] = await createFiles(root, 1) + if (!file) { + throw new Error('missing fixture') + } + const sidecar = join(root, 'metadata.json') + await fs.writeFile(sidecar, '{}') + const args = { + rootDir: root, + issues: [], + limit: 100, + agent: 'claude' as const, + extensions: ['.jsonl'], + contentDependencyPath: () => sidecar + } + const first = await discoverFiles(args) + await fs.writeFile(file, '{"new":true}\n') + await fs.utimes(file, 2_000, 2_000) + await fs.writeFile(sidecar, '{"new":true}') + await fs.utimes(sidecar, 3_000, 3_000) + const [added] = await createFiles(join(root, 'nested'), 1) + const second = await discoverFiles(args) + expect(second.files.map((entry) => entry.path)).toContain(added) + expect(second.files.find((entry) => entry.path === file)).toMatchObject({ + sizeBytes: 13, + mtimeMs: 2_000_000, + sidecar: { path: sidecar, mtimeMs: 3_000_000, sizeBytes: 12 } + }) + expect(first.files[0]?.sizeBytes).toBe(3) + await fs.rm(file) + expect((await discoverFiles(args)).files.map((entry) => entry.path)).not.toContain(file) + }) + + it('keeps issue ordering at the cap despite reversed metadata failures', async () => { + const paths = await createFiles(root, 65) + let releaseFirst = () => {} + const blocked = new Promise((resolve) => { + releaseFirst = resolve + }) + let reachedLater = () => {} + const later = new Promise((resolve) => { + reachedLater = resolve + }) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path === paths[0]) { + await blocked + } + if (path === paths[15]) { + reachedLater() + } + throw new Error(`unreadable ${path}`) + }) + const issues: AiVaultScanIssue[] = Array.from({ length: 498 }, (_, index) => ({ + agent: 'claude', + path: `previous-${index}`, + message: 'previous issue' + })) + const discovery = scan(root, issues) + try { + await later + } finally { + releaseFirst() + } + expect((await discovery).files).toEqual([]) + expect(issues).toHaveLength(500) + expect(issues[498]?.path).toBe(paths[0]) + expect(issues[499]?.kind).toBe('notice') + }) + + it('reports one unavailable sidecar in traversal order and distinguishes missing metadata', async () => { + const paths = await createFiles(root, 5) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path.endsWith('.meta')) { + throw Object.assign(new Error('metadata refused'), { + code: path === `${paths[0]}.meta` ? 'ENOENT' : 'EACCES' + }) + } + return fs.stat(path) + }) + const issues: AiVaultScanIssue[] = [] + const discovery = await discoverFiles({ + rootDir: root, + issues, + limit: 100, + agent: 'claude', + extensions: ['.jsonl'], + contentDependencyPath: (path) => `${path}.meta` + }) + expect(discovery.files.find((entry) => entry.path === paths[0])?.sidecar).toBe('none') + expect(discovery.files.filter((entry) => entry.sidecar === 'unknown')).toHaveLength(4) + expect(issues).toEqual([ + { + agent: 'claude', + path: `${paths[1]}.meta`, + message: 'Session metadata could not be read this scan.' + } + ]) + }) + + it.skipIf(process.platform === 'win32')( + 'reads retargeted and retargeted-back roots from disk', + async () => { + const [a] = await createFiles(join(root, 'a'), 1) + const [b] = await createFiles(join(root, 'b'), 1) + const alias = join(root, 'alias') + await fs.symlink(join(root, 'a'), alias, 'dir') + expect((await scan(alias)).files[0]?.sizeBytes).toBe(3) + if (!a || !b) { + throw new Error('missing fixture') + } + await fs.writeFile(b, '{"target":"b"}\n') + await fs.unlink(alias) + await fs.symlink(join(root, 'b'), alias, 'dir') + expect((await scan(alias)).files[0]?.sizeBytes).toBe(15) + await fs.writeFile(a, '{"target":"a"}\n') + await fs.unlink(alias) + await fs.symlink(join(root, 'a'), alias, 'dir') + expect((await scan(alias)).files[0]?.sizeBytes).toBe(15) + } + ) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index f7835dbdcc5e..84c2ffdd1e46 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,14 +1,34 @@ import type { Dirent } from 'node:fs' import { extname, join } from 'node:path' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { PrioritySemaphore } from '../../shared/priority-semaphore' +import { isWslUncPath } from '../../shared/wsl-paths' import { SessionNewestFiles } from './session-newest-files' import type { SessionSidecarObservation } from './session-sidecar-stat' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { recordSessionScanIssue } from './session-scan-issues' -import type { SessionFileDiscovery } from './session-scanner-types' +import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +const NATIVE_DISCOVERY_CONCURRENCY = 4 +const NATIVE_DISCOVERY_BATCH_SIZE = 16 +const nativeDiscoverySlots = new PrioritySemaphore(8) + +type SessionFileObservation = + | { file: Omit; sidecarPath?: string } + | { issue: { path: string; message: string } } + +async function withNativeDiscoverySlot(read: () => Promise): Promise { + const release = await nativeDiscoverySlots.acquire(0) + try { + return await read() + } finally { + release() + } +} + export async function discoverFiles(args: { rootDir: string limit: number @@ -21,50 +41,103 @@ export async function discoverFiles(args: { }): Promise { const files = new SessionNewestFiles(args.limit) let refusedSidecar = false - try { - await forEachSessionFile( - args.rootDir, - args.agent, - args.issues, - { - extensions: new Set(args.extensions), - filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate - }, - async (path) => { - try { - const fileStat = await wslGatedStat(path, 'scan') - const sidecarPath = await args.contentDependencyPath?.(path) - const sidecar = await observeSessionSidecar(sidecarPath) - if (sidecar === 'unknown' && !refusedSidecar) { - // One issue per root: a refused sibling is a property of the tree, - // not of each transcript that happens to point at it. - refusedSidecar = true - recordSessionScanIssue(args.issues, { - agent: args.agent, - path: sidecarPath ?? args.rootDir, - message: 'Session metadata could not be read this scan.' - }) - } - files.add({ - path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: new Date(fileStat.mtimeMs).toISOString(), - sizeBytes: fileStat.size, - sidecar, - dev: fileStat.dev, - ino: fileStat.ino, - nlink: fileStat.nlink - }) - } catch (err) { - recordSessionScanIssue(args.issues, { - agent: args.agent, - path, - message: errorMessage(err) - }) + let paths: string[] = [] + let pending: Promise | null = null + const native = !isWslUncPath(args.rootDir) + + function consume(observations: readonly SessionFileObservation[]): void { + for (const observation of observations) { + if ('issue' in observation) { + recordSessionScanIssue(args.issues, { agent: args.agent, ...observation.issue }) + continue + } + if (observation.file.sidecar === 'unknown' && !refusedSidecar) { + refusedSidecar = true + recordSessionScanIssue(args.issues, { + agent: args.agent, + path: observation.sidecarPath ?? args.rootDir, + message: 'Session metadata could not be read this scan.' + }) + } + try { + const modifiedAt = new Date(observation.file.mtimeMs) + if (Number.isNaN(modifiedAt.getTime()) || files.wouldRetain(observation.file.mtimeMs)) { + files.add({ ...observation.file, modifiedAt: modifiedAt.toISOString() }) } + } catch (err) { + recordSessionScanIssue(args.issues, { + agent: args.agent, + path: observation.file.path, + message: errorMessage(err) + }) } + } + } + + async function finishPending(): Promise { + if (pending) { + consume(await pending) + pending = null + } + } + + function queueNativeFile(path: string): void | Promise { + paths.push(path) + if (paths.length < NATIVE_DISCOVERY_BATCH_SIZE) { + return + } + return finishPending().then(() => { + pending = readBatch(paths) + paths = [] + }) + } + + async function readBatch(batch: readonly string[]): Promise { + const groups: string[][] = [] + const groupSize = Math.ceil(batch.length / NATIVE_DISCOVERY_CONCURRENCY) + for (let index = 0; index < batch.length; index += groupSize) { + groups.push(batch.slice(index, index + groupSize)) + } + const observations = await mapWithConcurrency(groups, NATIVE_DISCOVERY_CONCURRENCY, (group) => + withNativeDiscoverySlot(async () => { + const files: SessionFileObservation[] = [] + for (const path of group) { + files.push(await observeSessionFile(path, args.contentDependencyPath)) + } + return files + }) ) + return observations.flat() + } + + try { + try { + await forEachSessionFile( + args.rootDir, + args.agent, + args.issues, + { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate, + ...(native + ? { + readDirectory: (path: string) => + withNativeDiscoverySlot(() => wslGatedReaddir(path, 'scan')) + } + : {}) + }, + native + ? queueNativeFile + : async (path) => consume([await observeSessionFile(path, args.contentDependencyPath)]) + ) + } finally { + // One read batch overlaps directory traversal; commit in traversal order. + await finishPending() + if (paths.length) { + consume(await readBatch(paths)) + } + } } catch (err) { // Why: discoverAiVaultSessionSources fans out with Promise.all, so one // stalled distro would otherwise reject the whole vault scan — including @@ -82,6 +155,31 @@ export async function discoverFiles(args: { return { agent: args.agent, rootDir: args.rootDir, files: files.newest() } } +async function observeSessionFile( + path: string, + contentDependencyPath?: (path: string) => string | undefined | Promise +): Promise { + try { + const fileStat = await wslGatedStat(path, 'scan') + const sidecarPath = contentDependencyPath ? await contentDependencyPath(path) : undefined + const sidecar = sidecarPath ? await observeSessionSidecar(sidecarPath) : 'none' + return { + file: { + path, + mtimeMs: fileStat.mtimeMs, + sizeBytes: fileStat.size, + sidecar, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink + }, + sidecarPath + } + } catch (err) { + return { issue: { path, message: errorMessage(err) } } + } +} + /** * A sibling that cannot be statted is not "no sibling": it must not take the * transcript down with it, and it must not read as absent either, or the parse @@ -144,7 +242,7 @@ export async function forEachSessionFile( agent: AiVaultAgent, issues: AiVaultScanIssue[], options: SessionFileWalkOptions, - onFile: (path: string) => Promise, + onFile: (path: string) => void | Promise, depth = 0 ): Promise { options.signal?.throwIfAborted() @@ -179,7 +277,10 @@ export async function forEachSessionFile( options.extensions.has(extname(entry.name).toLowerCase()) && (options.filePredicate?.(fullPath) ?? true) ) { - await onFile(fullPath) + const observation = onFile(fullPath) + if (observation) { + await observation + } } } } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts index d9bc0b3a27c6..d4e66dd48b02 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -25,7 +25,7 @@ export async function handleOpenCodeSqliteRequest( if (request.kind === 'native-page' || request.kind === 'native-signal') { const value = request.kind === 'native-signal' - ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId) + ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId, request.agent) : readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 }) return { id: request.id, ok: true, value } } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index afee7778e3db..aea241b250f6 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -50,6 +50,8 @@ export type OpenCodeNativeChatReadRequest = { sessionId: string limit?: number beforeMessageRowId?: number + /** When 'zcode', reads apply ZCode's hidden-transcript filter. */ + agent?: 'zcode' } export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index e89a6361b1e2..7e6f5eb31e0d 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -265,6 +265,7 @@ export async function readOpenCodeTranscriptPageViaWorker( sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) { @@ -289,6 +290,7 @@ export async function readOpenCodeTranscriptSignalViaWorker( args: { dbPath: string sessionId: string + agent?: 'zcode' }, signal?: AbortSignal ) { diff --git a/src/main/ai-vault/session-scanner-qoder.test.ts b/src/main/ai-vault/session-scanner-qoder.test.ts index 78c99ddab886..356a05e01dd8 100644 --- a/src/main/ai-vault/session-scanner-qoder.test.ts +++ b/src/main/ai-vault/session-scanner-qoder.test.ts @@ -1,16 +1,10 @@ -import { readFile, mkdtemp, rm, stat } from 'node:fs/promises' -import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { tmpdir } from 'node:os' +import { readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources' -import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' -import { scanAiVaultSessions } from './session-scanner' import { createQoderSessionResumeState, parseQoderSessionContent } from './session-scanner-qoder-parser' -import { remoteSessionSources } from './remote-session-scanner-sources' import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' import type { TranscriptMessage } from './session-transcript-consumers' @@ -67,39 +61,3 @@ it('decodes a real generated and resumed Qoder 1.1.64 session for preview and se cwd: '/tmp/qoder-proof' }) }) - -it('discovers Qoder folder history and prunes nested workers on local, WSL, and SSH hosts', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-qoder-vault-')) - roots.push(root) - const options = isolatedScanRoots(root) - const path = join(options.qoderProjectsDir, 'folder', 'qoder-session.jsonl') - const records = [ - { type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/folder'] }, - { - type: 'user', - sessionId: 'qoder-session', - message: { role: 'user', content: 'Qoder folder proof' } - } - ] - await writeJsonlFile(path, records) - await writeJsonlFile( - join(options.qoderProjectsDir, 'folder', 'qoder-session', 'subagents', 'worker.jsonl'), - records - ) - const result = await scanAiVaultSessions({ ...options, wslHomeDirs: [] }) - const sessions = result.sessions.filter((s) => s.agent === 'qoder') - expect(sessions).toHaveLength(1) - expect(sessions[0]).toMatchObject({ title: 'Qoder folder proof', cwd: '/tmp/folder' }) - expect(AI_VAULT_AGENT_SOURCES.qoder?.rootDirs(options, ['/home/test'])).toContain( - join('/home/test', '.qoder', 'projects') - ) - for (const platform of ['darwin-arm64', 'linux-x64', 'win32-x64'] as const) { - const source = remoteSessionSources( - platform === 'win32-x64' ? 'C:\\Users\\test' : '/home/test', - getRemoteHostPlatform(platform) - ).find((s) => s.agent === 'qoder') - expect(source).toBeDefined() - expect(source?.partitionSubagentTranscripts).toBeDefined() - } - expect((await stat(path)).size).toBeGreaterThan(0) -}) diff --git a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts index 8e912021e5ff..a18b89646ccd 100644 --- a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts +++ b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts @@ -1,6 +1,5 @@ import { beforeEach, expect, it, vi } from 'vitest' import type * as SessionDedup from './session-root-dedup' -import type * as RemoteSessionParseCache from './remote-session-parse-cache' import type { AiVaultSession } from '../../shared/ai-vault-types' const fixture = vi.hoisted((): { sessions: AiVaultSession[]; visits: number } => ({ @@ -28,16 +27,6 @@ vi.mock('./session-scanner-parse-cache', () => ({ }), parseAgentSessionFileCached: async (candidate: { session: AiVaultSession }) => candidate.session })) -vi.mock('./remote-session-scanner-sources', () => ({ remoteSessionSources: () => [{}] })) -vi.mock('./remote-session-scanner-discovery', () => ({ - discoverRemoteSourceCandidates: async () => candidates() -})) -vi.mock('./remote-session-parse-cache', async (original) => ({ - ...(await original()), - remoteSessionParseHostKey: () => 'fixture', - parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) => - candidate.session -})) vi.mock('./session-root-dedup', async (original) => { const actual = await original() return { @@ -50,7 +39,6 @@ vi.mock('./session-root-dedup', async (original) => { }) import { scanAiVaultSessions } from './session-scanner' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup' function candidates() { @@ -93,62 +81,42 @@ beforeEach(() => { fixture.visits = 0 }) -for (const host of ['local', 'remote'] as const) { - const scan = (unlimited: boolean, limit?: number) => - host === 'local' - ? scanAiVaultSessions({ unlimited, limit }) - : scanRemoteAiVaultSessions({ - unlimited, - limit, - provider: { readDir: vi.fn(), readFile: vi.fn(), stat: vi.fn() }, - executionHostId: 'local', - remoteHome: '/fixture', - hostPlatform: { - relayPlatform: 'linux-x64', - os: 'linux', - arch: 'x64', - pathFlavor: 'posix', - commandDialect: 'posix', - pathSeparator: '/', - pathDelimiter: ':' - } - }) +const scan = (unlimited: boolean, limit?: number) => scanAiVaultSessions({ unlimited, limit }) - it(`${host}: load-all processes deduplication linearly and retains late canonical aliases`, async () => { - fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i)) - fixture.sessions[0] = { - ...fixture.sessions[0]!, - codexHome: '/custom', - filePath: '/custom/rollout-0.jsonl' - } - fixture.sessions.push(session(0)) - const expected = dedupeScannedSessions(fixture.sessions) - fixture.visits = 0 - const started = performance.now() - const result = await scan(true) - process.stdout.write( - `${JSON.stringify({ host, candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n` - ) - expect(result.issues).toEqual([]) - expect(result.sessions).toEqual(expected) - expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2) - }, 30000) +it('load-all processes deduplication linearly and retains late canonical aliases', async () => { + fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i)) + fixture.sessions[0] = { + ...fixture.sessions[0]!, + codexHome: '/custom', + filePath: '/custom/rollout-0.jsonl' + } + fixture.sessions.push(session(0)) + const expected = dedupeScannedSessions(fixture.sessions) + fixture.visits = 0 + const started = performance.now() + const result = await scan(true) + process.stdout.write( + `${JSON.stringify({ candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n` + ) + expect(result.issues).toEqual([]) + expect(result.sessions).toEqual(expected) + expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2) +}, 30000) - it(`${host}: capped scans still fill the unique-session budget`, async () => { - fixture.sessions = [ - session(0), - ...Array.from({ length: 8 }, () => ({ - ...session(0), - filePath: '/custom/rollout-0.jsonl', - codexHome: '/custom' - })), - ...Array.from({ length: 10 }, (_, i) => session(i + 1)) - ] - const result = await scan(false, 10) - expect(result.sessions).toHaveLength(10) - expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10) - }) -} +it('capped scans still fill the unique-session budget', async () => { + fixture.sessions = [ + session(0), + ...Array.from({ length: 8 }, () => ({ + ...session(0), + filePath: '/custom/rollout-0.jsonl', + codexHome: '/custom' + })), + ...Array.from({ length: 10 }, (_, i) => session(i + 1)) + ] + const result = await scan(false, 10) + expect(result.sessions).toHaveLength(10) + expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10) +}) it('incremental canonical selection preserves winner occurrence order, ties and repeated references', () => { const collection = new ScannedSessionCollection() diff --git a/src/main/ai-vault/ssh-session-list.test.ts b/src/main/ai-vault/ssh-session-list.test.ts deleted file mode 100644 index 52454dda966e..000000000000 --- a/src/main/ai-vault/ssh-session-list.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' -import { - createSshDisposalError, - SSH_MUX_REQUEST_TIMEOUT_CODE -} from '../ssh/ssh-channel-multiplexer' - -const requestActiveSshAiVaultSessionList = vi.fn() -const getActiveSshAiVaultHostInfo = vi.fn() -const getSshFilesystemProvider = vi.fn() -const scanRemoteAiVaultSessions = vi.fn() - -vi.mock('../ipc/ssh', () => ({ - requestActiveSshAiVaultSessionList: (...args: unknown[]) => - requestActiveSshAiVaultSessionList(...args), - getActiveSshAiVaultHostInfo: (...args: unknown[]) => getActiveSshAiVaultHostInfo(...args) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: (...args: unknown[]) => getSshFilesystemProvider(...args), - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH filesystem is unavailable.' -})) - -vi.mock('./remote-session-scanner', () => ({ - scanRemoteAiVaultSessions: (...args: unknown[]) => scanRemoteAiVaultSessions(...args) -})) - -const { scanSshAiVaultSessions } = await import('./ssh-session-list') - -describe('scanSshAiVaultSessions', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useRealTimers() - getActiveSshAiVaultHostInfo.mockReturnValue({ remoteHome: '/home/dev', hostPlatform: 'linux' }) - getSshFilesystemProvider.mockReturnValue({}) - }) - - it('returns at the deadline when the legacy crawl ignores abort', async () => { - // Relay without the method resolves null, so the leg falls through to the - // desktop crawl — which used to run unbounded even under an all-host budget. - vi.useFakeTimers() - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - let fallbackSignal: AbortSignal | undefined - scanRemoteAiVaultSessions.mockImplementation(({ signal }: { signal?: AbortSignal }) => { - fallbackSignal = signal - return new Promise(() => {}) - }) - - const resultPromise = scanSshAiVaultSessions('dev-box', undefined, { timeoutMs: 20 }) - await vi.advanceTimersByTimeAsync(20) - const result = await Promise.race([resultPromise, Promise.resolve('still-pending' as const)]) - - expect(result).not.toBe('still-pending') - expect(fallbackSignal?.aborted).toBe(true) - if (result === 'still-pending') { - return - } - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ - executionHostId: 'ssh:dev-box', - kind: 'host', - message: 'Agent Session History scan timed out after 20ms on this SSH host.' - }) - ]) - }) - - it('leaves the crawl unbounded when no budget was requested', async () => { - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) - - await scanSshAiVaultSessions('dev-box') - - expect(scanRemoteAiVaultSessions).toHaveBeenCalledWith( - expect.objectContaining({ signal: undefined }) - ) - }) - - it('reports an unexpected crawl failure as a host issue instead of rejecting', async () => { - // Why: `all` scope awaits every host leg together, so a throw here would - // discard the local sessions alongside this host's. - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockRejectedValue(new TypeError('provider blew up')) - - const result = await scanSshAiVaultSessions('dev-box') - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'provider blew up' }) - ]) - }) - - it('bounds the relay round trip separately from the whole leg', async () => { - // The relay needs seconds to walk a real remote home; only the leg total - // has to stay short enough that one host cannot hold the merge open. - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) - - await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - expect.any(Object), - expect.objectContaining({ timeoutMs: 15_000 }) - ) - }) - - it('keeps a relay scan that outran the old three-second bound', async () => { - // Regression: the shared 3s budget emptied healthy hosts in the all-hosts - // view. A relay answering inside the scan budget must keep its sessions. - requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId: string, _params: unknown, options: { timeoutMs?: number }) => - (options.timeoutMs ?? 0) > 3_000 - ? Promise.resolve({ - sessions: [remoteSession()], - issues: [], - scannedAt: '2026-08-02T00:00:00.000Z' - }) - : Promise.reject(relayTimeoutError()) - ) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() - }) - - it('reports a host issue when the relay timed out on a real scan budget', async () => { - // A relay that had a fair scan window will not answer faster over the far - // slower filesystem crawl, so retrying it only stalls the merge. - requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' }) - ]) - }) - - it('reports a host issue when the relay link was declared lost on a real scan budget', async () => { - // Declaring a wedged link lost trades SSH_MUX_REQUEST_TIMEOUT for CONNECTION_LOST on this leg. - // Both are unverifiable, so both must surface as a host issue rather than falling through to a - // crawl that would publish an authoritative-looking empty list - // (docs/reference/ssh-execution-boundary.md). - requestActiveSshAiVaultSessionList.mockRejectedValue(createSshDisposalError('connection_lost')) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' }) - ]) - }) - - it('still falls back when the relay budget was too short for a fair attempt', async () => { - requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - scanRemoteAiVaultSessions.mockResolvedValue({ - sessions: [remoteSession()], - issues: [], - scannedAt: '2026-08-02T00:00:00.000Z' - }) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 3_000, - relayTimeoutMs: 2_000 - }) - - expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('does not treat an unrelated error mentioning a timeout as a relay timeout', async () => { - requestActiveSshAiVaultSessionList.mockRejectedValue( - new Error('the remote agent timed out after loading its index') - ) - scanRemoteAiVaultSessions.mockResolvedValue({ - sessions: [remoteSession()], - issues: [], - scannedAt: '2026-08-02T00:00:00.000Z' - }) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('reports the relay error when the fallback crawl recovered nothing', async () => { - // Otherwise a broken relay over an empty crawl reads as a healthy but empty - // host, and the panel shows no reason for the missing sessions. - requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay method exploded')) - scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) - - const result = await scanSshAiVaultSessions('dev-box') - - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'relay method exploded' }) - ]) - }) - - it('still propagates a caller cancellation', async () => { - const controller = new AbortController() - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockImplementation(() => new Promise(() => {})) - - const result = scanSshAiVaultSessions('dev-box', undefined, { - signal: controller.signal, - timeoutMs: 5_000 - }) - await vi.waitFor(() => expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)) - controller.abort() - - await expect(result).rejects.toMatchObject({ name: 'AbortError' }) - }) -}) - -function emptyResult(): AiVaultListResult { - return { sessions: [], issues: [], scannedAt: '2026-08-02T00:00:00.000Z' } -} - -/** Mirrors the multiplexer's typed timeout: the leg branches on the code, not - * on the message text, so a look-alike message must not take that branch. */ -function relayTimeoutError(): Error { - return Object.assign(new Error('Request "aiVault.listSessions" timed out after 15000ms'), { - code: SSH_MUX_REQUEST_TIMEOUT_CODE - }) -} - -// Relay rows are re-validated before they are trusted, so this has to satisfy -// the full session schema rather than a partial stub. -function remoteSession(): AiVaultSession { - return { - id: 'ssh:dev-box:codex:remote-session:/home/dev/remote-session.jsonl', - executionHostId: 'ssh:dev-box', - agent: 'codex', - sessionId: 'remote-session', - title: 'remote-session', - cwd: '/home/dev/repo', - branch: null, - model: null, - filePath: '/home/dev/remote-session.jsonl', - codexHome: null, - createdAt: null, - updatedAt: '2026-08-02T00:00:00.000Z', - modifiedAt: '2026-08-02T00:00:00.000Z', - messageCount: 1, - totalTokens: 0, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'codex resume remote-session', - subagent: null - } -} diff --git a/src/main/ai-vault/ssh-session-list.ts b/src/main/ai-vault/ssh-session-list.ts deleted file mode 100644 index bb1bd18c4dac..000000000000 --- a/src/main/ai-vault/ssh-session-list.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - AI_VAULT_SCOPE_PATHS_MAX_COUNT, - type AiVaultListArgs, - type AiVaultListResult, - type AiVaultScanIssue -} from '../../shared/ai-vault-types' -import { toSshExecutionHostId } from '../../shared/execution-host' -import { - getSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-filesystem-dispatch' -import { getActiveSshAiVaultHostInfo, requestActiveSshAiVaultSessionList } from '../ipc/ssh' -import { isSshRequestOutcomeUnverifiable } from '../ssh/ssh-channel-multiplexer' -import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { parseAiVaultListResult } from './session-list-result-validation' -import { aiVaultScanIssueResult, restampAiVaultListResult } from './session-list-results' - -/** Bounds one SSH host leg. `relayTimeoutMs` caps the relay round trip alone; - * `timeoutMs` caps the whole leg, including the legacy filesystem crawl. */ -export type SshAiVaultScanBudget = { - signal?: AbortSignal - timeoutMs?: number - relayTimeoutMs?: number -} - -// Why: a relay that was given a real scan budget and still timed out will not -// answer faster over the slower filesystem crawl, so that leg reports a host -// issue. A relay cut short below this never got a fair attempt — those fall -// back rather than emptying a healthy host's sessions (#12178 follow-up). -const MEANINGFUL_RELAY_SCAN_ATTEMPT_MS = 10_000 - -export async function scanSshAiVaultSessions( - targetId: string, - args?: AiVaultListArgs, - options: SshAiVaultScanBudget = {} -): Promise { - const executionHostId = toSshExecutionHostId(targetId) - // Why: in `all` scope every host leg is awaited together, so an unexpected - // throw here (not a caller cancellation) would discard the local results too. - try { - return await scanOneSshHost(targetId, executionHostId, args, options) - } catch (error) { - if (isAbortError(error)) { - throw error - } - return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) - } -} - -async function scanOneSshHost( - targetId: string, - executionHostId: `ssh:${string}`, - args: AiVaultListArgs | undefined, - options: SshAiVaultScanBudget -): Promise { - const startedAt = Date.now() - // Both legs scan the same capped set, so the fallback can't quietly scan more - // paths — or skip the truncation notice — than the relay leg would. - const scopePaths = args?.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT) - const scopePathsTruncated = (args?.scopePaths?.length ?? 0) > AI_VAULT_SCOPE_PATHS_MAX_COUNT - let relayError: unknown - const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs - try { - const params = { - ...(args?.includeAntigravityIdeSessions === true - ? { includeAntigravityIdeSessions: true } - : {}), - limit: args?.limit, - ...(args?.unlimited === true ? { unlimited: true } : {}), - ...(args?.force === true ? { force: true } : {}), - scopePaths, - ...(scopePathsTruncated ? { scopePathsTruncated: true } : {}) - } - const relayResult = - options.signal || relayTimeoutMs !== undefined - ? await requestActiveSshAiVaultSessionList(targetId, params, { - signal: options.signal, - timeoutMs: relayTimeoutMs - }) - : await requestActiveSshAiVaultSessionList(targetId, params) - if (relayResult !== null) { - return restampAiVaultListResult(parseAiVaultListResult(relayResult), executionHostId) - } - } catch (error) { - if (isAbortError(error)) { - throw error - } - if ( - isSshRequestOutcomeUnverifiable(error) && - (relayTimeoutMs === undefined || relayTimeoutMs >= MEANINGFUL_RELAY_SCAN_ATTEMPT_MS) - ) { - return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) - } - relayError = error - } - const hostInfo = getActiveSshAiVaultHostInfo(targetId) - const provider = getSshFilesystemProvider(targetId) - if (!hostInfo || !provider) { - return sshScanIssueResult( - executionHostId, - targetId, - relayError ? errorMessage(relayError) : SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE - ) - } - // Why: `timeoutMs` bounded only the relay round trip, so a host on a relay - // without the list method fell through to the unbounded desktop crawl and one - // stalled SSH file stream could hold every other host's results hostage. - const fallbackResult = await scanRemoteSessionsWithinBudget({ - scan: (signal) => - scanRemoteAiVaultSessions({ - provider, - executionHostId, - includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions, - remoteHome: hostInfo.remoteHome, - hostPlatform: hostInfo.hostPlatform, - ...(args?.includeAntigravityIdeSessions === true - ? { includeAntigravityIdeSessions: true } - : {}), - limit: args?.limit, - unlimited: args?.unlimited, - scopePaths, - signal - }), - signal: options.signal, - remainingMs: remainingScanBudgetMs(options.timeoutMs, startedAt) - }) - if (!fallbackResult) { - return sshScanIssueResult( - executionHostId, - targetId, - `Agent Session History scan timed out after ${options.timeoutMs}ms on this SSH host.` - ) - } - const scopeIssues = scopePathsTruncated - ? [scopeTruncationIssue(executionHostId, hostInfo.remoteHome)] - : [] - // An empty remote home and "the relay method failed and the crawl found - // nothing" look identical, so a fallback that recovered nothing still reports - // the relay error instead of presenting a broken relay as an empty host. - if (!relayError || fallbackResult.sessions.length > 0) { - return { ...fallbackResult, issues: [...fallbackResult.issues, ...scopeIssues] } - } - return { - ...fallbackResult, - issues: [ - ...sshScanIssueResult(executionHostId, targetId, errorMessage(relayError)).issues, - ...fallbackResult.issues, - ...scopeIssues - ] - } -} - -/** Budget left for the legacy crawl after the relay attempt spent part of it. */ -function remainingScanBudgetMs(timeoutMs: number | undefined, startedAt: number): number | null { - if (timeoutMs === undefined) { - return null - } - return Math.max(0, timeoutMs - (Date.now() - startedAt)) -} - -/** Runs the legacy crawl under `remainingMs`; resolves null once the budget is - * spent. Caller cancellation still propagates as an AbortError. */ -async function scanRemoteSessionsWithinBudget(args: { - scan: (signal?: AbortSignal) => Promise - signal?: AbortSignal - remainingMs: number | null -}): Promise { - const remainingMs = args.remainingMs - if (remainingMs === null) { - return args.scan(args.signal) - } - if (args.signal?.aborted) { - throw createAiVaultScanCancelledError() - } - if (remainingMs === 0) { - return null - } - const controller = new AbortController() - return new Promise((resolve, reject) => { - let settled = false - const finish = (settle: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - args.signal?.removeEventListener('abort', onCallerAbort) - settle() - } - const onCallerAbort = (): void => { - controller.abort() - finish(() => reject(createAiVaultScanCancelledError())) - } - const timer = setTimeout(() => { - controller.abort() - finish(() => resolve(null)) - }, remainingMs) - args.signal?.addEventListener('abort', onCallerAbort, { once: true }) - if (args.signal?.aborted) { - onCallerAbort() - return - } - try { - const scan = args.scan(controller.signal) - // The SSH provider may ignore abort; observe any eventual rejection after - // this caller has already returned its timeout or cancellation result. - void scan.then( - (result) => finish(() => resolve(result)), - (error: unknown) => finish(() => reject(error)) - ) - } catch (error) { - finish(() => reject(error)) - } - }) -} - -// Mirrors the notice the relay leg appends so both legs report the same cap. -function scopeTruncationIssue( - executionHostId: `ssh:${string}`, - remoteHome: string -): AiVaultScanIssue { - return { - executionHostId, - agent: 'codex', - kind: 'scope', - path: remoteHome, - message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` - } -} - -function sshScanIssueResult( - executionHostId: `ssh:${string}`, - targetId: string, - message: string -): AiVaultListResult { - return aiVaultScanIssueResult({ executionHostId, path: targetId, message }) -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError' -} - -function errorMessage(error: unknown): string { - return error instanceof Error - ? error.message - : 'Agent Session History scan failed on the SSH target.' -} diff --git a/src/main/claude/claude-hook-event-install.test.ts b/src/main/claude/claude-hook-event-install.test.ts index 99af9a3fa536..f27a35ad7c14 100644 --- a/src/main/claude/claude-hook-event-install.test.ts +++ b/src/main/claude/claude-hook-event-install.test.ts @@ -14,7 +14,7 @@ const topLevelSettings: Record = fixture.topLevelSet function install(config: HooksConfig, claudeVersion: string | undefined): HooksConfig { return applyManagedHooks( config, - managedHook, + () => managedHook, SCRIPT_FILE_NAME, getClaudeManagedHookPlan(claudeVersion) ) diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index 225a52423c3a..f57ac35fed66 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -109,7 +109,7 @@ describe('Claude compact hook registration', () => { it('writes PostCompact, and no PreCompact, into the settings Claude actually reads', () => { const written = applyManagedHooks( { hooks: {} }, - { type: 'command', command: 'orca-claude-hook' }, + () => ({ type: 'command', command: 'orca-claude-hook' }), 'claude-hook.sh', getClaudeManagedHookPlan(CLAUDE_HOOK_EVENT_FIRST_VERSIONS.PostCompact) ) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 58a62d797e6e..ba32d9eb051a 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -32,11 +32,11 @@ import { getManagedScriptFileName, getConfigPath, getManagedCommand, - getManagedLifecycleHook, + getManagedEventHook, getManagedScriptPath, getPosixManagedScriptFileName, getRemoteConfigPath, - getRemoteManagedCommand, + getRemoteManagedEventCommand, getStatusLineInstallMarkerPath, getStatusLineScriptFileName, getStatusLineScriptPath, @@ -122,10 +122,10 @@ export class ClaudeHookService { } // Why: report partial registration instead of a false installed state. - const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 for (const event of this.managedHookPlan(options).install) { + const expectedHook = getManagedEventHook(event.eventName, scriptPath, this.options.settings) const definitions = Array.isArray(config.hooks?.[event.eventName]) ? config.hooks![event.eventName]! : [] @@ -186,11 +186,10 @@ export class ClaudeHookService { } } - const hook = getManagedLifecycleHook(scriptPath, this.options.settings) const plan = this.managedHookPlan(options) let nextConfig = applyManagedHooks( config, - hook, + (eventName) => getManagedEventHook(eventName, scriptPath, this.options.settings), getManagedScriptFileName(this.options.settings), plan ) @@ -275,10 +274,10 @@ export class ClaudeHookService { } // Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home. - const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath)) const nextConfig = applyManagedHooks( config, - hook, + (eventName) => + buildManagedCommandHook(getRemoteManagedEventCommand(eventName, remoteScriptPath)), remoteScriptFileName, this.managedHookPlan(options) ) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 3e15f26c4be8..328e53fffcdc 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -14,7 +14,10 @@ import { type HooksConfig } from '../agent-hooks/installer-utils' import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' -import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' +import { + wrapRuntimeHomeHookCommand, + wrapRuntimeHomePosixHookCommand +} from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' @@ -83,16 +86,34 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json` } +function getScriptBaseName(scriptPath: string): string { + const scriptFileName = basename(scriptPath) + const extension = extname(scriptFileName) + return extension ? scriptFileName.slice(0, -extension.length) : scriptFileName +} + export function getManagedCommand( scriptPath: string, options: { neutralJsonWhenMissing?: boolean } = {} ): string { - const scriptFileName = basename(scriptPath) - const extension = extname(scriptFileName) - return wrapRuntimeHomeHookCommand( - extension ? scriptFileName.slice(0, -extension.length) : scriptFileName, - options - ) + return wrapRuntimeHomeHookCommand(getScriptBaseName(scriptPath), options) +} + +export function getManagedEventHook( + eventName: string, + scriptPath: string, + settings = CLAUDE_HOOK_SETTINGS +): HookCommandConfig { + return process.platform === 'win32' + ? getManagedLifecycleHook(scriptPath, settings) + : buildManagedCommandHook(getRemoteManagedEventCommand(eventName, scriptPath)) +} + +// Claude adds the full PostCompact command to the model's context after compaction. +export function getRemoteManagedEventCommand(eventName: string, scriptPath: string): string { + return eventName === 'PostCompact' + ? wrapRuntimeHomePosixHookCommand(getScriptBaseName(scriptPath)) + : getRemoteManagedCommand(scriptPath) } export function getManagedLifecycleHook( @@ -155,7 +176,7 @@ export function getRemoteManagedCommand(scriptPath: string): string { export function applyManagedHooks( config: HooksConfig, - hook: HookCommandConfig, + hookForEvent: (eventName: string) => HookCommandConfig, scriptFileName: string, plan: ClaudeManagedHookPlan ): HooksConfig { @@ -165,7 +186,10 @@ export function applyManagedHooks( for (const event of plan.install) { const current = nextHooks[event.eventName] const cleaned = Array.isArray(current) ? removeManagedCommands(current, isManagedCommand) : [] - const definition: HookDefinition = { ...event.definition, hooks: [hook] } + const definition: HookDefinition = { + ...event.definition, + hooks: [hookForEvent(event.eventName)] + } nextHooks[event.eventName] = [...cleaned, definition] } diff --git a/src/main/claude/post-compact-hook-command.test.ts b/src/main/claude/post-compact-hook-command.test.ts new file mode 100644 index 000000000000..98d4c0b36388 --- /dev/null +++ b/src/main/claude/post-compact-hook-command.test.ts @@ -0,0 +1,176 @@ +import { runProcess } from '@orca/process-host' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentHookMemorySftp } from '../agent-hooks/agent-hook-memory-sftp.test-fixture' +import { wrapRuntimeHomePosixHookCommand } from '../agent-hooks/runtime-home-hook-command' +import { ClaudeHookService } from './hook-service' +import { + CLAUDE_HOOK_SETTINGS, + getManagedEventHook, + getManagedLifecycleHook, + getRemoteManagedCommand, + getRemoteManagedEventCommand +} from './hook-settings' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/userData' } })) + +const SCRIPT_PATH = '/home/test/.orca/agent-hooks/claude-hook.sh' +const CURRENT_CLAUDE = { claudeVersion: '2.1.261 (Claude Code)' } +// Why: Claude prints the command verbatim after every /compact; the full wrapper is ~3KB. +const MAX_ECHOED_COMMAND_LENGTH = 400 + +afterEach(() => vi.restoreAllMocks()) + +describe('PostCompact managed hook command', () => { + it.each(['darwin', 'linux'] as const)('is the short POSIX form on %s', (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const { command } = getManagedEventHook('PostCompact', SCRIPT_PATH) + + expect(command.length).toBeLessThan(MAX_ECHOED_COMMAND_LENGTH) + expect(command).toContain('if [ -n "${HOME-}" ] && ') + expect(command).toContain('/bin/sh "${HOME-}/.orca/agent-hooks/claude-hook.sh"') + expect(command).toContain('ORCA_HOOK_AGENT_PID="${PPID:-}"') + expect(command).not.toMatch(/powershell|OSTYPE|\/home\/test/i) + }) + + it('leaves every other event on the full wrapper', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + expect(getManagedEventHook('Stop', SCRIPT_PATH)).toEqual( + getManagedLifecycleHook(SCRIPT_PATH, CLAUDE_HOOK_SETTINGS) + ) + }) + + it('keeps the Windows launcher on win32, whose bare script path is already short', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const windowsPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + expect(getManagedEventHook('PostCompact', windowsPath)).toEqual( + getManagedLifecycleHook(windowsPath, CLAUDE_HOOK_SETTINGS) + ) + }) + + // Why every client platform: a remote host is POSIX even when Orca runs on Windows. + it.each(['darwin', 'linux', 'win32'] as const)( + 'uses the short POSIX form only for remote PostCompact from %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + expect(getRemoteManagedEventCommand('PostCompact', SCRIPT_PATH)).toBe( + wrapRuntimeHomePosixHookCommand('claude-hook') + ) + expect(getRemoteManagedEventCommand('Stop', SCRIPT_PATH)).toBe( + getRemoteManagedCommand(SCRIPT_PATH) + ) + } + ) +}) + +describe.skipIf(process.platform === 'win32')('PostCompact command execution', () => { + let home: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-post-compact-')) + }) + + afterEach(() => rmSync(home, { recursive: true, force: true })) + + function run(command: string, env: NodeJS.ProcessEnv) { + return runProcess({ + program: '/bin/sh', + args: ['-c', command], + env: { PATH: '/usr/bin:/bin', ...env }, + input: 'x'.repeat(1_000_000), + timeoutMs: 5_000 + }) + } + + it('runs the HOME script with the agent pid captured', async () => { + const dir = join(home, '.orca', 'agent-hooks') + mkdirSync(dir, { recursive: true }) + const script = join(dir, 'claude-hook.sh') + writeFileSync(script, '#!/bin/sh\ncat >/dev/null\nprintf "%s" "$ORCA_HOOK_AGENT_PID"\nexit 7\n') + chmodSync(script, 0o755) + + const result = await run(getManagedEventHook('PostCompact', SCRIPT_PATH).command, { + HOME: home + }) + + expect(result.code, result.stderr).toBe(7) + expect(result.stdout).toBe(String(process.pid)) + }) + + it.each([ + ['the script is missing', (dir: string) => ({ HOME: dir })], + ['HOME is empty', () => ({ HOME: '' })], + ['HOME is unset', () => ({})] + ])('drains stdin silently when %s', async (_label, env) => { + const result = await run(getManagedEventHook('PostCompact', SCRIPT_PATH).command, env(home)) + + expect(result.code).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toBe('') + }) + + it.each([{}, { HOME: '' }])('skips script lookup without a runtime HOME: %j', async (env) => { + const lookupProbe = [ + 'probe_test() {', + ' case "$1" in -f|-r|-x) printf "unexpected script lookup: %s\\n" "$2" >&2; return 1 ;;', + ' *) command [ "$@" ;; esac', + '}', + 'alias [=probe_test', + getManagedEventHook('PostCompact', SCRIPT_PATH).command + ].join('\n') + + const result = await run(lookupProbe, env) + + expect(result.code).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toBe('') + }) +}) + +describe.skipIf(process.platform === 'win32')('ClaudeHookService PostCompact install', () => { + it('writes the short form, reports installed, and replaces an old full-wrapper entry', () => { + const home = mkdtempSync(join(tmpdir(), 'orca-post-compact-install-')) + vi.stubEnv('HOME', home) + try { + const settingsPath = join(home, '.claude', 'settings.json') + mkdirSync(join(home, '.claude'), { recursive: true }) + const fullWrapper = getManagedLifecycleHook(SCRIPT_PATH).command + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + PostCompact: [{ hooks: [{ type: 'command', command: fullWrapper }] }] + } + }) + ) + + const status = new ClaudeHookService().install(CURRENT_CLAUDE) + + expect(status.state).toBe('installed') + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(written.hooks.PostCompact).toEqual([ + { hooks: [getManagedEventHook('PostCompact', SCRIPT_PATH)] } + ]) + expect(written.hooks.Stop[0].hooks[0].command).toBe(fullWrapper) + } finally { + vi.unstubAllEnvs() + rmSync(home, { recursive: true, force: true }) + } + }) +}) + +describe('ClaudeHookService remote PostCompact install', () => { + it('writes the short form for PostCompact and the full wrapper elsewhere', async () => { + const { sftp, fs } = createAgentHookMemorySftp() + await new ClaudeHookService().installRemote(sftp, '/home/dev', CURRENT_CLAUDE) + + const written = JSON.parse(fs.files.get('/home/dev/.claude/settings.json') ?? '{}') + const remoteScript = '/home/dev/.orca/agent-hooks/claude-hook.sh' + expect(written.hooks.PostCompact[0].hooks[0].command).toBe( + getRemoteManagedEventCommand('PostCompact', remoteScript) + ) + expect(written.hooks.Stop[0].hooks[0].command).toBe(getRemoteManagedCommand(remoteScript)) + }) +}) diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index ba9e8c6b7127..30f049bf9956 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -46,6 +46,30 @@ describe('prependOrcaCliDirToChildPath', () => { expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() } ) + it('puts a Windows execution host CLI first on Path without a POSIX restore dir', () => { + const binDir = join(USER_DATA, 'cli', 'bin') + const launcher = join(binDir, 'orca.exe') + installFakeAppEnvironment({ getCliLauncherPath: () => launcher }) + const env: Record = { + Path: `C:\\Windows;${binDir};;C:\\Tools`, + ORCA_USER_DATA_PATH: 'C:\\parent\\profile', + ORCA_CLI_COMMAND: 'C:\\parent\\orca.exe', + ORCA_CLI_BIN_DIR: '/parent/bin' + } + expect( + prependOrcaCliDirToChildPath(env, { + isPackaged: true, + userDataPath: USER_DATA, + resourcesPath: null, + platform: 'win32' + }) + ).toBe(launcher) + expect(env).toEqual({ + Path: `${binDir};C:\\Windows;C:\\Tools`, + ORCA_USER_DATA_PATH: USER_DATA, + ORCA_CLI_COMMAND: launcher + }) + }) it('leads packaged Linux PATH with the bare-orca shim dir', () => { // Why this matters at all: the Linux CLI installs as `orca-ide` so it never claims GNOME // Orca's /usr/bin/orca screen reader, so bare `orca` only works through this shim. diff --git a/src/main/codex/codex-hook-file-entry-binary-contract.test.ts b/src/main/codex/codex-hook-file-entry-binary-contract.test.ts index e0ea77444cbb..47aee2a70bbe 100644 --- a/src/main/codex/codex-hook-file-entry-binary-contract.test.ts +++ b/src/main/codex/codex-hook-file-entry-binary-contract.test.ts @@ -1,5 +1,6 @@ import { execFile, execFileSync } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -11,6 +12,9 @@ import { import { createServer, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { StringDecoder } from 'node:string_decoder' +import { createCodexComposerReadyScanner } from '../../shared/codex-composer-ready-scanner' +import { createTerminalEscapeScanner } from '../../shared/terminal-escape-scanner' import { promisify } from 'node:util' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { CodexListedHook } from './codex-app-server-client' @@ -277,8 +281,9 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 enabled: true } ]) - const { screen } = await runCodexTui() + const { screen, posts } = await runCodexTui(accountHome(), true) expect(screen).toMatch(/eeds?review/i) + expect(posts).toEqual([]) } ) @@ -403,11 +408,13 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 /** A TUI Codex in a pty: start, type a prompt, quit; the screen without spaces or escapes. */ async function runCodexTui( - codexHome: string | null = accountHome() + codexHome: string | null = accountHome(), + expectReview = false ): Promise<{ screen: string; posts: string[] }> { const codexHomeEnv = codexHome ? { CODEX_HOME: codexHome } : {} const posts: string[] = [] - const receiver = await listen(recordPosts(posts)) + const control = mkdtempSync(join(root, 'tui-control-')) + const receiver = await listen(recordPosts(posts, control)) const model = await startMockResponses() // Why resolved: Codex 0.158 matches the trusted project only by the real path, and a symlinked // HOME (the ~/.codex case) left it untrusted, so the turn never ran and no hook posted. @@ -418,7 +425,7 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 env: { PATH: process.env.PATH, HOME: home, ...codexHomeEnv } }) try { - await execFileAsync( + const run = execFileAsync( 'python3', [ '-c', @@ -426,6 +433,8 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 out, 'say hi', workdir, + control, + expectReview ? 'review' : 'turn', '--', binary!, ...(help.stdout.includes('--no-daemon') ? ['--no-daemon'] : []), @@ -440,6 +449,7 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 ], { timeout: TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, env: { PATH: process.env.PATH, HOME: home, @@ -452,6 +462,40 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 } } ) + const composer = createCodexComposerReadyScanner() + const decoder = new StringDecoder('utf8') + let painted = '' + const text = createTerminalEscapeScanner({ + onCsi: () => {}, + onText: (chunk) => { + painted = (painted + chunk).slice(-16_384) + } + }) + const mark = (name: string): void => { + const file = join(control, name) + if (!existsSync(file)) { + writeFileSync(file, '') + } + } + run.child.stdout?.on('data', (bytes: Buffer) => { + const chunk = decoder.write(bytes) + const ready = composer.observe(chunk).ready + text.observe(chunk) + const folded = painted.replace(/\s+/g, '') + if (/eeds?review/i.test(folded)) { + mark('review-started') + } + if (/\d+hooks?n?eeds?reviewbeforeitcanrun/i.test(folded)) { + mark('review-table') + } + if (ready && /\b(?:default|minimal|low|medium|high|xhigh)\s*·/.test(painted)) { + mark('ready') + } + if (folded.includes('•hi') && ready) { + mark('assistant-painted') + } + }) + await run } finally { model.close() receiver.close() @@ -465,36 +509,57 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 const PTY_DRIVER = ` import os, pty, re, sys, time, select, signal, struct, fcntl, termios out, prompt, cwd = sys.argv[1], sys.argv[2], sys.argv[3] +control, expected = sys.argv[4], sys.argv[5] cmd = sys.argv[sys.argv.index('--') + 1:] -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd); os.execv(cmd[0], cmd) -fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 40, 120, 0, 0)) +def send(data): + os.write(fd, data) buf = b'' -def pump(sec): +def present(name): + return os.path.isfile(os.path.join(control, name)) +def pump(sec, done=None): global buf - end = time.time() + sec - while time.time() < end: + end = time.monotonic() + sec + while time.monotonic() < end: + if done and done(): return True r, _, _ = select.select([fd], [], [], 0.2) if r: - try: - d = os.read(fd, 65536) - except OSError: - return - if not d: return + try: d = os.read(fd, 65536) + except OSError: return False + if not d: return False buf += d - if b'\\x1b[6n' in d: os.write(fd, b'\\x1b[1;1R') -pump(8) -os.write(fd, prompt.encode()); time.sleep(0.5); os.write(fd, b'\\r'); pump(10) -for _ in range(3): - try: os.write(fd, b'\\x03') - except OSError: break - pump(1) -try: os.kill(pid, signal.SIGKILL) -except ProcessLookupError: pass -buf = re.sub(rb'\\x1b\\[[0-9;?<>=]*[A-Za-z]', b'', buf) -buf = re.sub(rb'\\x1b\\][^\\x07]*\\x07', b'', buf) -open(out, 'wb').write(buf) + os.write(sys.stdout.fileno(), d) + if b'\\x1b[6n' in d: send(b'\\x1b[1;1R') + return bool(done and done()) +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd); os.execv(cmd[0], cmd) +try: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 40, 120, 0, 0)) + start = 'review-started' if expected == 'review' else 'ready' + if not pump(8, lambda: present(start)): + raise RuntimeError('Codex did not paint its expected startup state within 8 seconds') + send(prompt.encode()); time.sleep(0.5); send(b'\\r') + if expected == 'review': + done = lambda: present('review-table') and not present('stop') + else: + done = lambda: present('stop') and present('assistant-painted') + if not pump(10, done): + raise RuntimeError('Codex did not deliver/paint its expected turn or review within 10 seconds') +finally: + try: + for _ in range(3): + try: send(b'\\x03') + except OSError: break + pump(1) + finally: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: pass + finally: + waited_pid, status = os.waitpid(pid, 0) + buf = re.sub(rb'\\x1b\\[[0-9;?<>=]*[A-Za-z]', b'', buf) + buf = re.sub(rb'\\x1b\\][^\\x07]*\\x07', b'', buf) + open(out, 'wb').write(buf) ` function hasPython(): boolean { @@ -506,11 +571,32 @@ function hasPython(): boolean { } } -function recordPosts(posts: string[]): Server { +function recordPosts(posts: string[], control?: string): Server { return createServer((request, response) => { + const chunks: Buffer[] = [] + if (control) { + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + } request.resume() request.on('end', () => { posts.push(request.url ?? '') + if (control && request.url === '/hook/codex') { + const raw = Buffer.concat(chunks).toString('utf8') + const form = new URLSearchParams(raw) + try { + const payload: unknown = JSON.parse(form.get('payload') ?? raw) + if ( + typeof payload === 'object' && + payload !== null && + 'hook_event_name' in payload && + payload.hook_event_name === 'Stop' + ) { + response.once('finish', () => writeFileSync(join(control, 'stop'), raw)) + } + } catch { + /* A malformed owner payload must not satisfy the delivery gate. */ + } + } response.writeHead(204).end() }) }) diff --git a/src/main/computer/macos-computer-use-permission-status-cleanup.integration.test.ts b/src/main/computer/macos-computer-use-permission-status-cleanup.integration.test.ts new file mode 100644 index 000000000000..4a78022c95b3 --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-status-cleanup.integration.test.ts @@ -0,0 +1,84 @@ +import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' +import { afterEach, describe, expect, it } from 'vitest' +import { stopMacOSPermissionStatusHelper } from './macos-computer-use-permission-status-cleanup' + +describe.skipIf(process.platform !== 'darwin')('real permission status helper cleanup', () => { + const children: ChildProcessHandle[] = [] + const directories: string[] = [] + + afterEach(async () => { + await Promise.all( + children.splice(0).map(async (child) => { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const exited = once(child, 'exit') + child.kill('SIGKILL') + await exited + }) + ) + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) + }) + + async function startFixture(executablePath: string, args: string[]): Promise { + const child = spawnProcess({ + program: process.execPath, + args: [ + '-e', + "setInterval(() => {}, 1000); process.stdout.write('ready')", + '--', + executablePath, + ...args + ], + stdio: 'pipe' + }) + children.push(child) + await once(child.stdout, 'data') + return child + } + + it('removes completed probes without stopping another probe, setup, or agent', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-permission-[cleanup]+-')) + directories.push(directory) + const statusPath = join(directory, 'status.json') + const executablePath = join( + directory, + 'Orca Computer Use.app', + 'Contents', + 'MacOS', + 'orca-computer-use-macos' + ) + const peers = await Promise.all([ + startFixture(executablePath, ['--permission-status-file', `${statusPath}.peer`]), + startFixture(executablePath, ['--permission-status-file', join(directory, 'peer.json')]), + startFixture(executablePath, ['--permission', 'accessibility']), + startFixture(executablePath, ['--permissions']), + startFixture(executablePath, ['--agent', statusPath]), + startFixture(`${executablePath}-peer`, ['--permission-status-file', statusPath]) + ]) + + for (let probe = 0; probe < 3; probe++) { + const completed = await startFixture(executablePath, ['--permission-status-file', statusPath]) + const exited = once(completed, 'exit') + + await stopMacOSPermissionStatusHelper(statusPath) + await exited + + expect(completed.signalCode).toBe('SIGTERM') + expect( + children.filter((child) => child.exitCode === null && child.signalCode === null) + ).toHaveLength(peers.length) + } + for (const peer of peers) { + expect(peer.exitCode).toBeNull() + expect(peer.signalCode).toBeNull() + } + }) +}) diff --git a/src/main/computer/macos-computer-use-permission-status-cleanup.ts b/src/main/computer/macos-computer-use-permission-status-cleanup.ts new file mode 100644 index 000000000000..71c4d1f504ef --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-status-cleanup.ts @@ -0,0 +1,15 @@ +import { runProcess } from '@orca/process-host' +import { escapeRegex } from '../../shared/string-utils' + +export async function stopMacOSPermissionStatusHelper(statusPath: string): Promise { + // The unique status path distinguishes this completed probe from concurrent probes and setup helpers. + await runProcess({ + program: '/usr/bin/pkill', + args: [ + '-f', + `(^|[[:space:]/])orca-computer-use-macos[[:space:]]+--permission-status-file[[:space:]]+${escapeRegex(statusPath)}$` + ], + timeoutMs: 2_000, + stdio: 'ignore' + }).catch(() => undefined) +} diff --git a/src/main/computer/macos-computer-use-permission-status.test.ts b/src/main/computer/macos-computer-use-permission-status.test.ts index cfa511b11ce0..4a5cf2ce3f1d 100644 --- a/src/main/computer/macos-computer-use-permission-status.test.ts +++ b/src/main/computer/macos-computer-use-permission-status.test.ts @@ -2,12 +2,16 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process' import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { escapeRegex } from '../../shared/string-utils' const resolveHelperAppPathMock = vi.hoisted(() => vi.fn()) const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) +const runProcessMock = vi.hoisted(() => vi.fn()) const permissionStatusTempDir = '/tmp/orca-computer-use-permissions-test' const permissionStatusPath = join(permissionStatusTempDir, 'status.json') +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) + vi.mock('child_process', () => ({ execFileSync: vi.fn(), spawn: vi.fn(() => { @@ -51,6 +55,14 @@ describe('getComputerUsePermissionStatus', () => { vi.mocked(readFile).mockReset() vi.mocked(rm).mockReset() vi.mocked(stat).mockReset() + runProcessMock.mockReset() + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) resolveHelperAppPathMock.mockReset() resolveHelperExecutablePathMock.mockReset() resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') @@ -93,6 +105,7 @@ describe('getComputerUsePermissionStatus', () => { recursive: true, force: true }) + expectOnlyOwnStatusHelperStopped() }) it('removes permission status helper listeners after close', async () => { @@ -156,6 +169,7 @@ describe('getComputerUsePermissionStatus', () => { expect(settled).toBe(true) await rejection expect(child.kill).toHaveBeenCalled() + expectOnlyOwnStatusHelperStopped() expect(rm).toHaveBeenCalledWith(permissionStatusTempDir, { recursive: true, force: true @@ -187,6 +201,7 @@ describe('getComputerUsePermissionStatus', () => { { stdio: ['ignore', 'pipe', 'pipe'] } ) expect(spawnSync).not.toHaveBeenCalled() + expectOnlyOwnStatusHelperStopped() expect(readFile).toHaveBeenCalledWith(permissionStatusPath, 'utf8') expect(rm).toHaveBeenCalledWith(permissionStatusTempDir, { recursive: true, @@ -208,9 +223,96 @@ describe('getComputerUsePermissionStatus', () => { ] }) expect(execFileSync).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('cleans up when the status file is invalid', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + mockPermissionStatus('{') + + await expect(getComputerUsePermissionStatus()).rejects.toBeInstanceOf(SyntaxError) + + expectOnlyOwnStatusHelperStopped() + }) + + it('cleans up when no status file arrives before the timeout', async () => { + vi.useFakeTimers() + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + vi.mocked(stat).mockRejectedValue(new Error('ENOENT')) + const rejection = expect(getComputerUsePermissionStatus()).rejects.toMatchObject({ + code: 'accessibility_error', + message: 'Timed out checking permissions' + }) + + await vi.advanceTimersByTimeAsync(5000) + await rejection + + expectOnlyOwnStatusHelperStopped() + }) + + it('returns the status while helper cleanup is still pending', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + let finishCleanup = (): void => {} + runProcessMock.mockReturnValue( + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + + await expect(getComputerUsePermissionStatus()).resolves.toMatchObject({ + helperUnavailableReason: null + }) + expectOnlyOwnStatusHelperStopped() + finishCleanup() + }) + + it('preserves the status when helper cleanup cannot start', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + runProcessMock.mockRejectedValue(new Error('spawn ENOENT')) + + await expect(getComputerUsePermissionStatus()).resolves.toMatchObject({ + helperUnavailableReason: null + }) + expectOnlyOwnStatusHelperStopped() + }) + + it('targets each concurrent check separately', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + const peerDirectory = `${permissionStatusTempDir}-peer` + vi.mocked(mkdtemp) + .mockResolvedValueOnce(permissionStatusTempDir) + .mockResolvedValueOnce(peerDirectory) + + await Promise.all([getComputerUsePermissionStatus(), getComputerUsePermissionStatus()]) + + expect(runProcessMock).toHaveBeenCalledTimes(2) + for (const directory of [permissionStatusTempDir, peerDirectory]) { + expect(runProcessMock).toHaveBeenCalledWith({ + program: '/usr/bin/pkill', + args: [ + '-f', + `(^|[[:space:]/])orca-computer-use-macos[[:space:]]+--permission-status-file[[:space:]]+${escapeRegex(join(directory, 'status.json'))}$` + ], + timeoutMs: 2000, + stdio: 'ignore' + }) + } }) }) +function expectOnlyOwnStatusHelperStopped(): void { + expect(runProcessMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledWith({ + program: '/usr/bin/pkill', + args: [ + '-f', + `(^|[[:space:]/])orca-computer-use-macos[[:space:]]+--permission-status-file[[:space:]]+${escapeRegex(permissionStatusPath)}$` + ], + timeoutMs: 2000, + stdio: 'ignore' + }) +} + function mockPermissionStatus(json: string): void { vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) vi.mocked(readFile).mockResolvedValue(json) diff --git a/src/main/computer/macos-computer-use-permission-status.ts b/src/main/computer/macos-computer-use-permission-status.ts index b23c3e9ab96e..2cc5b01bb026 100644 --- a/src/main/computer/macos-computer-use-permission-status.ts +++ b/src/main/computer/macos-computer-use-permission-status.ts @@ -13,6 +13,7 @@ import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' import { RuntimeClientError } from './runtime-client-error' +import { stopMacOSPermissionStatusHelper } from './macos-computer-use-permission-status-cleanup' const PERMISSION_STATUS_HELPER_LAUNCH_TIMEOUT_MS = 5_000 @@ -95,6 +96,8 @@ async function readPermissionStatusFromHelperApp( } throw new RuntimeClientError('accessibility_error', 'Timed out checking permissions') } finally { + // Do not wait for helper cleanup before returning its permission status. + void stopMacOSPermissionStatusHelper(statusPath) await rm(tempDir, { recursive: true, force: true }) } } diff --git a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts index 0633c811ba74..aadd4eb10400 100644 --- a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts +++ b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts @@ -1,3 +1,15 @@ +import type * as FsPromises from 'node:fs/promises' +import type { Session } from './session' +import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names' +import type * as FullReader from '../../shared/process-table-snapshot-reader' +import { + createProcessTablePsFixture, + type PsFixtureRow +} from '../../shared/process-table-ps-test-fixture' +import type * as CheapReader from '../../shared/cheap-process-table-snapshot-reader' +import type * as TrackerModule from './pty-subprocess/foreground-process-tracker' +import type * as Inspection from './terminal-host-process-inspection' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Measurement for the cheap-tier process inspection. Drives the REAL daemon inspection // entrypoint (`inspectTerminalHostProcess`) for 8 idle agent panes over a simulated 60s idle // cadence (POLL_TIER_INTERVAL_MS.idle = 2,000ms) and counts `ps` forks BY COLUMN SET: a fork @@ -8,7 +20,6 @@ // // The second test is the zero-trade-off proof: the same tick sequence, including an agent exit // and a restart, produces the identical foregroundProcess series with the cheap tier on and off. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Two seams because the two tiers spawn differently: the full evidence reader still forks // through node:child_process, the cheap reader through Orca's runProcess entry point. @@ -19,11 +30,24 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({ vi.mock('node:child_process', () => ({ execFile: execFileMock })) vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) -import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader' -import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' -import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker' -import { inspectTerminalHostProcess } from './terminal-host-process-inspection' -import type { Session } from './session' +// Inject /dev data without bypassing the production translator. +vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => { + const original = await importOriginal() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: async (path: string) => readProcStatMock(path) +})) + +let cheapReader: typeof CheapReader +let fullReader: typeof FullReader +let trackerModule: typeof TrackerModule +let inspection: typeof Inspection const PANE_COUNT = 8 const IDLE_POLL_INTERVAL_MS = 2_000 // POLL_TIER_INTERVAL_MS.idle @@ -33,50 +57,92 @@ const TICKS = Math.floor((WINDOW_SECONDS * 1000) / IDLE_POLL_INTERVAL_MS) const shellPid = (pane: number): number => 1000 + pane * 100 const agentPid = (pane: number): number => shellPid(pane) + 1 -type PaneState = { agent: boolean; agentStart: string } +type PaneState = { agent: boolean; agentStart: string; agentStartTicks: number } const panes: PaneState[] = Array.from({ length: PANE_COUNT }, () => ({ agent: true, - agentStart: 'Thu Sep 3 16:02:05 2026' + agentStart: 'Thu Sep 3 16:02:05 2026', + agentStartTicks: 1_400 })) const forks = { full: 0, cheap: 0 } -function renderRows(): { full: string; cheap: string } { - const full: string[] = [] - const cheap: string[] = [] - panes.forEach((pane, i) => { - const s = shellPid(i) - const a = agentPid(i) - const tpgid = pane.agent ? a : s - const shellStat = pane.agent ? 'Ss' : 'Ss+' - cheap.push(`${s} 1 ${s} ${tpgid} ${shellStat} Thu Sep 3 16:02:01 2026`) - full.push(`${s} 1 ${s} ${tpgid} ${shellStat} ttys00${i} Thu Sep 3 16:02:01 2026 -zsh`) +function processRows(): PsFixtureRow[] { + return panes.flatMap((pane, i) => { + const shell = shellPid(i) + const agent = agentPid(i) + const rows: PsFixtureRow[] = [ + { + pid: shell, + ppid: 1, + pgid: shell, + tpgid: pane.agent ? agent : shell, + stat: pane.agent ? 'Ss' : 'Ss+', + terminalMinor: i, + startTime: 'Thu Sep 3 16:02:01 2026', + startTicks: 1_000, + command: '-zsh' + } + ] if (pane.agent) { - cheap.push(`${a} ${s} ${a} ${a} S+ ${pane.agentStart}`) - full.push(`${a} ${s} ${a} ${a} S+ ttys00${i} ${pane.agentStart} node /usr/local/bin/claude`) + rows.push({ + pid: agent, + ppid: shell, + pgid: agent, + tpgid: agent, + stat: 'S+', + terminalMinor: i, + startTime: pane.agentStart, + startTicks: pane.agentStartTicks, + command: 'node /usr/local/bin/claude' + }) } + return rows }) - return { full: `${full.join('\n')}\n`, cheap: `${cheap.join('\n')}\n` } } +const psFixture = createProcessTablePsFixture(processRows) +const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path)) function installCountingPs(): void { - execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { - expect(args[1]).toContain('command=') - forks.full += 1 - ;(cb as (err: unknown, r: { stdout: string; stderr: string }) => void)(null, { - stdout: renderRows().full, - stderr: '' - }) - }) - runProcessMock.mockImplementation(async (spec: { args: readonly string[] }) => { - expect(spec.args[1]).not.toContain('command=') + execFileMock.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + callback: (err: unknown, r: { stdout: string; stderr: string }) => void + ) => { + expect(cmd).toBe('ps') + expect(args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command='] + ) + forks.full += 1 + callback(null, { + stdout: psFixture.render(args[1]), + stderr: '' + }) + } + ) + runProcessMock.mockImplementation(async (spec: { program: string; args: readonly string[] }) => { + expect(spec.program).toBe('ps') + expect(spec.args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat='] + ) forks.cheap += 1 - return { code: 0, signal: null, stdout: renderRows().cheap, stderr: '', timedOut: false } + return { + code: 0, + signal: null, + stdout: psFixture.render(spec.args[1]), + stderr: '', + timedOut: false + } }) } function createSession(pane: number): Session { - const tracker = createPtyForegroundProcessTracker({ + const tracker = trackerModule.createPtyForegroundProcessTracker({ process: { pid: shellPid(pane), get process() { @@ -106,7 +172,7 @@ async function settle(): Promise { async function runTick(sessions: Session[], steadyState: boolean): Promise<(string | null)[]> { const results = await Promise.all( sessions.map((session, pane) => - inspectTerminalHostProcess({ + inspection.inspectTerminalHostProcess({ sessionId: `wt:pane-${pane}`, session, ...(steadyState ? { steadyState: true } : {}), @@ -119,22 +185,30 @@ async function runTick(sessions: Session[], steadyState: boolean): Promise<(stri return results.map((r) => r.foregroundProcess) } -describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { +describe.each(['darwin', 'linux'])('cheap-tier ps scan volume (%s)', (hostPlatform) => { let platform: PropertyDescriptor | undefined - beforeEach(() => { + beforeEach(async () => { execFileMock.mockReset() + readProcStatMock.mockClear() runProcessMock.mockReset() - resetProcessTableSnapshotForTests() - resetCheapProcessTableSnapshotForTests() forks.full = 0 forks.cheap = 0 panes.forEach((pane) => { pane.agent = true pane.agentStart = 'Thu Sep 3 16:02:05 2026' + pane.agentStartTicks = 1_400 }) platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + // Column constants are chosen at import time, so each host needs a fresh module graph. + vi.resetModules() + fullReader = await import('../../shared/process-table-snapshot-reader') + fullReader.resetProcessTableSnapshotForTests() + cheapReader = await import('../../shared/cheap-process-table-snapshot-reader') + cheapReader.resetCheapProcessTableSnapshotForTests() + trackerModule = await import('./pty-subprocess/foreground-process-tracker') + inspection = await import('./terminal-host-process-inspection') vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(1_000_000) installCountingPs() @@ -145,6 +219,15 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } + if (hostPlatform === 'darwin') { + expect(readProcStatMock).not.toHaveBeenCalled() + } else { + expect(readProcStatMock).toHaveBeenCalled() + } + // Production catches read errors; assert each path resolved against the fixture at read time. + for (const [index, result] of readProcStatMock.mock.results.entries()) { + expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return') + } }) it('replaces ~all full captures with cheap ones once every pane holds an anchor', async () => { @@ -176,11 +259,12 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { it('completion detection is byte-for-byte unchanged: exit, idle, and restart resolve identically with and without the cheap tier', async () => { const script = async (steadyState: boolean): Promise<(string | null)[][]> => { - resetProcessTableSnapshotForTests() - resetCheapProcessTableSnapshotForTests() + fullReader.resetProcessTableSnapshotForTests() + cheapReader.resetCheapProcessTableSnapshotForTests() panes.forEach((pane) => { pane.agent = true pane.agentStart = 'Thu Sep 3 16:02:05 2026' + pane.agentStartTicks = 1_400 }) const sessions = Array.from({ length: PANE_COUNT }, (_, pane) => createSession(pane)) const series: (string | null)[][] = [] @@ -192,6 +276,7 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { if (tick === 12) { panes[2].agent = true // ...and is restarted with a new start time panes[2].agentStart = 'Thu Sep 3 16:30:00 2026' + panes[2].agentStartTicks = 2_000 } if (tick === 20) { panes[6].agent = false diff --git a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts index dc91c8918a22..d624be84eab7 100644 --- a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts +++ b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts @@ -1,3 +1,16 @@ +import type * as FsPromises from 'node:fs/promises' +import type { Session } from './session' +import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names' +import type * as FullReader from '../../shared/process-table-snapshot-reader' +import { + createProcessTablePsFixture, + type PsFixtureRow +} from '../../shared/process-table-ps-test-fixture' +import type * as CheapReader from '../../shared/cheap-process-table-snapshot-reader' +import type * as TrackerModule from './pty-subprocess/foreground-process-tracker' +import type * as Inspection from './terminal-host-process-inspection' +import type { TerminalHostInspectionTier } from './terminal-host-process-inspection' +import type * as AnchorModule from './terminal-host-steady-state-anchor' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Two seams because the two tiers spawn differently: the full evidence reader still forks @@ -9,15 +22,25 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({ vi.mock('node:child_process', () => ({ execFile: execFileMock })) vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) -import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader' -import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' -import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker' -import { - inspectTerminalHostProcess, - type TerminalHostInspectionTier -} from './terminal-host-process-inspection' -import { getSteadyStateAnchor } from './terminal-host-steady-state-anchor' -import type { Session } from './session' +// Inject /dev data without bypassing the production translator. +vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => { + const original = await importOriginal() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: async (path: string) => readProcStatMock(path) +})) + +let cheapReader: typeof CheapReader +let fullReader: typeof FullReader +let trackerModule: typeof TrackerModule +let inspection: typeof Inspection +let anchorModule: typeof AnchorModule const SHELL_PID = 4242 const AGENT_PID = 4300 @@ -26,60 +49,99 @@ const START_AGENT = 'Thu Sep 3 16:02:05 2026' type Table = { agent: 'claude' | 'stopped' | 'gone' | 'replaced'; children?: number } -/** One host table rendered in both column sets, so each fork answers by the args it asked for. */ -function renderTable(table: Table): { full: string; cheap: string } { +const forks = { full: 0, cheap: 0 } +let table: Table = { agent: 'claude' } + +function processRows(): PsFixtureRow[] { const shellTpgid = table.agent === 'claude' || table.agent === 'replaced' ? AGENT_PID : SHELL_PID - const shellStat = shellTpgid === SHELL_PID ? 'Ss+' : 'Ss' - const rows: { cheap: string; full: string }[] = [ + const rows: PsFixtureRow[] = [ { - cheap: `${SHELL_PID} 1 ${SHELL_PID} ${shellTpgid} ${shellStat} ${START_SHELL}`, - full: `${SHELL_PID} 1 ${SHELL_PID} ${shellTpgid} ${shellStat} ttys004 ${START_SHELL} -zsh` + pid: SHELL_PID, + ppid: 1, + pgid: SHELL_PID, + tpgid: shellTpgid, + stat: shellTpgid === SHELL_PID ? 'Ss+' : 'Ss', + terminalMinor: 4, + startTime: START_SHELL, + startTicks: 1_000, + command: '-zsh' }, { - cheap: `9000 1 9000 9000 Ss+ Thu Sep 3 12:00:00 2026`, - full: `9000 1 9000 9000 Ss+ ttys009 Thu Sep 3 12:00:00 2026 -zsh` + pid: 9000, + ppid: 1, + pgid: 9000, + tpgid: 9000, + stat: 'Ss+', + terminalMinor: 9, + startTime: 'Thu Sep 3 12:00:00 2026', + startTicks: 500, + command: '-zsh' } ] if (table.agent !== 'gone') { - const stat = table.agent === 'stopped' ? 'T' : 'S+' - const start = table.agent === 'replaced' ? 'Thu Sep 3 16:30:00 2026' : START_AGENT rows.push({ - cheap: `${AGENT_PID} ${SHELL_PID} ${AGENT_PID} ${shellTpgid} ${stat} ${start}`, - full: `${AGENT_PID} ${SHELL_PID} ${AGENT_PID} ${shellTpgid} ${stat} ttys004 ${start} node /usr/local/bin/claude` + pid: AGENT_PID, + ppid: SHELL_PID, + pgid: AGENT_PID, + tpgid: shellTpgid, + stat: table.agent === 'stopped' ? 'T' : 'S+', + terminalMinor: 4, + startTime: table.agent === 'replaced' ? 'Thu Sep 3 16:30:00 2026' : START_AGENT, + startTicks: table.agent === 'replaced' ? 2_000 : 1_400, + command: 'node /usr/local/bin/claude' }) for (let i = 0; i < (table.children ?? 0); i += 1) { - const pid = AGENT_PID + 10 + i rows.push({ - cheap: `${pid} ${AGENT_PID} ${AGENT_PID} ${shellTpgid} S+ Thu Sep 3 16:05:0${i} 2026`, - full: `${pid} ${AGENT_PID} ${AGENT_PID} ${shellTpgid} S+ ttys004 Thu Sep 3 16:05:0${i} 2026 rg --files` + pid: AGENT_PID + 10 + i, + ppid: AGENT_PID, + pgid: AGENT_PID, + tpgid: shellTpgid, + stat: 'S+', + terminalMinor: 4, + startTime: `Thu Sep 3 16:05:0${i} 2026`, + startTicks: 1_500 + i, + command: 'rg --files' }) } } - return { - full: `${rows.map((r) => r.full).join('\n')}\n`, - cheap: `${rows.map((r) => r.cheap).join('\n')}\n` - } + return rows } - -const forks = { full: 0, cheap: 0 } -let table: Table = { agent: 'claude' } +const psFixture = createProcessTablePsFixture(processRows) +const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path)) function installPs(): void { - execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { - expect(args[1]).toContain('command=') - forks.full += 1 - ;(cb as (err: unknown, r: { stdout: string; stderr: string }) => void)(null, { - stdout: renderTable(table).full, - stderr: '' - }) - }) - runProcessMock.mockImplementation(async (spec: { args: readonly string[] }) => { - expect(spec.args[1]).not.toContain('command=') + execFileMock.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + callback: (err: unknown, r: { stdout: string; stderr: string }) => void + ) => { + expect(cmd).toBe('ps') + expect(args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command='] + ) + forks.full += 1 + callback(null, { + stdout: psFixture.render(args[1]), + stderr: '' + }) + } + ) + runProcessMock.mockImplementation(async (spec: { program: string; args: readonly string[] }) => { + expect(spec.program).toBe('ps') + expect(spec.args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat='] + ) forks.cheap += 1 return { code: 0, signal: null, - stdout: renderTable(table).cheap, + stdout: psFixture.render(spec.args[1]), stderr: '', timedOut: false } @@ -88,7 +150,7 @@ function installPs(): void { function createSession(processName: () => string): Session { let dead = false - const tracker = createPtyForegroundProcessTracker({ + const tracker = trackerModule.createPtyForegroundProcessTracker({ process: { pid: SHELL_PID, get process() { @@ -120,10 +182,10 @@ async function inspect( options: { steadyState?: boolean; expectedIncarnationId?: string } = {} ): Promise<{ tier: TerminalHostInspectionTier - result: Awaited> + result: Awaited> }> { let tier: TerminalHostInspectionTier = 'full' - const result = await inspectTerminalHostProcess({ + const result = await inspection.inspectTerminalHostProcess({ sessionId: 'wt-1:pane-1', session, ...options, @@ -147,19 +209,27 @@ async function advance(ms: number): Promise { vi.setSystemTime(Date.now() + ms) } -describe('daemon cheap-tier process inspection', () => { +describe.each(['darwin', 'linux'])('daemon cheap-tier process inspection (%s)', (hostPlatform) => { let platform: PropertyDescriptor | undefined - beforeEach(() => { + beforeEach(async () => { execFileMock.mockReset() + readProcStatMock.mockClear() runProcessMock.mockReset() - resetProcessTableSnapshotForTests() - resetCheapProcessTableSnapshotForTests() forks.full = 0 forks.cheap = 0 table = { agent: 'claude' } platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + // Column constants are chosen at import time, so each host needs a fresh module graph. + vi.resetModules() + fullReader = await import('../../shared/process-table-snapshot-reader') + fullReader.resetProcessTableSnapshotForTests() + cheapReader = await import('../../shared/cheap-process-table-snapshot-reader') + cheapReader.resetCheapProcessTableSnapshotForTests() + trackerModule = await import('./pty-subprocess/foreground-process-tracker') + inspection = await import('./terminal-host-process-inspection') + anchorModule = await import('./terminal-host-steady-state-anchor') vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(1_000_000) installPs() @@ -170,6 +240,13 @@ describe('daemon cheap-tier process inspection', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } + if (hostPlatform === 'darwin') { + expect(readProcStatMock).not.toHaveBeenCalled() + } + // Production catches read errors; assert each path resolved against the fixture at read time. + for (const [index, result] of readProcStatMock.mock.results.entries()) { + expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return') + } }) /** Bring a session to a recognized anchor the way production does: one full cadence tick. */ @@ -179,7 +256,11 @@ describe('daemon cheap-tier process inspection', () => { await settle() expect(first.tier).toBe('full') expect(first.result.foregroundProcess).toBe('claude') - expect(getSteadyStateAnchor(session)?.agentName).toBe('claude') + expect(first.result.foregroundProcessEvidence).toMatchObject({ + verdict: 'live', + processName: 'claude' + }) + expect(anchorModule.getSteadyStateAnchor(session)?.agentName).toBe('claude') return session } @@ -209,6 +290,9 @@ describe('daemon cheap-tier process inspection', () => { } expect(forks.cheap).toBe(4) expect(forks.full).toBe(fullBefore) + if (hostPlatform === 'linux') { + expect(readProcStatMock).toHaveBeenCalled() + } }) it('a request without steadyState (old client, remote client, restore path) always gets the full capture with evidence', async () => { @@ -286,7 +370,7 @@ describe('daemon cheap-tier process inspection', () => { table = { agent: 'gone' } await advance(2_000) await inspect(session, { steadyState: true }) - expect(getSteadyStateAnchor(session)).toBeNull() + expect(anchorModule.getSteadyStateAnchor(session)).toBeNull() // Back with a new agent, but the pane must re-anchor via a FULL capture first. table = { agent: 'claude' } await advance(2_000) diff --git a/src/main/ephemeral-vm-runtime-environment-release.test.ts b/src/main/ephemeral-vm-runtime-environment-release.test.ts new file mode 100644 index 000000000000..e563cfc9b1a0 --- /dev/null +++ b/src/main/ephemeral-vm-runtime-environment-release.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + remove: vi.fn(), + removeManaged: vi.fn(), + closeTunnel: vi.fn(async () => {}) +})) + +vi.mock('../shared/runtime-environment-store', () => ({ + listEnvironments: mocks.list, + removeEnvironment: mocks.remove +})) +vi.mock('../shared/runtime-environment-managed-orcad-store', () => ({ + removeManagedOrcadEnvironment: mocks.removeManaged +})) +vi.mock('./ssh/orcad-managed-tunnel', () => ({ closeOrcadManagedTunnel: mocks.closeTunnel })) + +import { releaseEphemeralVmRuntimeEnvironments } from './ephemeral-vm-runtime-environment-release' + +describe('releaseEphemeralVmRuntimeEnvironments', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("closes a destroyed VM's managed server tunnel and forgets the server", async () => { + mocks.list.mockReturnValue([ + { id: 'env-vm', orcadDeployment: { sshTargetId: 'runtime-ssh-vm' } }, + { id: 'env-other', orcadDeployment: { sshTargetId: 'ssh-builder' } } + ]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { + runtimeEnvironmentId: 'env-vm', + sshTargetId: 'runtime-ssh-vm' + }) + expect(mocks.closeTunnel).toHaveBeenCalledTimes(1) + expect(mocks.closeTunnel).toHaveBeenCalledWith('env-vm') + expect(mocks.removeManaged).toHaveBeenCalledTimes(1) + expect(mocks.removeManaged).toHaveBeenCalledWith('/user-data', 'env-vm') + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it('finds the server of a relay-era VM that converted without recording it', async () => { + mocks.list.mockReturnValue([ + { id: 'env-converted', orcadDeployment: { sshTargetId: 'runtime-ssh-vm' } } + ]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { sshTargetId: 'runtime-ssh-vm' }) + expect(mocks.removeManaged).toHaveBeenCalledWith('/user-data', 'env-converted') + }) + + it('removes a paired recipe server the ordinary way', async () => { + mocks.list.mockReturnValue([{ id: 'env-paired' }]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { + runtimeEnvironmentId: 'env-paired' + }) + expect(mocks.remove).toHaveBeenCalledWith('/user-data', 'env-paired') + expect(mocks.closeTunnel).not.toHaveBeenCalled() + }) + + it('ignores a server that is already gone', async () => { + mocks.list.mockReturnValue([]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { runtimeEnvironmentId: 'env-gone' }) + expect(mocks.remove).not.toHaveBeenCalled() + expect(mocks.removeManaged).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ephemeral-vm-runtime-environment-release.ts b/src/main/ephemeral-vm-runtime-environment-release.ts new file mode 100644 index 000000000000..affe5437fbaa --- /dev/null +++ b/src/main/ephemeral-vm-runtime-environment-release.ts @@ -0,0 +1,26 @@ +import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' +import { removeManagedOrcadEnvironment } from '../shared/runtime-environment-managed-orcad-store' +import { listEnvironments, removeEnvironment } from '../shared/runtime-environment-store' + +/** Forgets the servers a destroyed VM ran; a managed one's tunnel would otherwise keep redialing. */ +export async function releaseEphemeralVmRuntimeEnvironments( + userDataPath: string, + runtime: Pick +): Promise { + // Why the target too: a relay-era VM converted on connect never recorded its server's id. + const environments = listEnvironments(userDataPath).filter( + (entry) => + entry.id === runtime.runtimeEnvironmentId || + (runtime.sshTargetId !== undefined && + entry.orcadDeployment?.sshTargetId === runtime.sshTargetId) + ) + for (const environment of environments) { + if (!environment.orcadDeployment) { + removeEnvironment(userDataPath, environment.id) + continue + } + const { closeOrcadManagedTunnel } = await import('./ssh/orcad-managed-tunnel') + await closeOrcadManagedTunnel(environment.id).catch(() => undefined) + removeManagedOrcadEnvironment(userDataPath, environment.id) + } +} diff --git a/src/main/ephemeral-vm-runtime-ssh.test.ts b/src/main/ephemeral-vm-runtime-ssh.test.ts new file mode 100644 index 000000000000..236a4ba37965 --- /dev/null +++ b/src/main/ephemeral-vm-runtime-ssh.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + remove: vi.fn(async () => {}), + gitProvider: vi.fn(), + fsProvider: vi.fn() +})) + +vi.mock('./ipc/ssh', () => ({ + connectRegisteredSshTarget: mocks.connect, + getSshConnectionStore: () => ({ + upsertRuntimeOwnedTarget: (runtimeId: string) => ({ + id: `runtime-ssh-${runtimeId}`, + label: 'VM', + host: 'vm', + port: 22, + username: 'root' + }) + }) +})) +vi.mock('./ipc/ssh-session-teardown', () => ({ + disconnectRegisteredSshTarget: vi.fn(), + removeRegisteredSshTarget: mocks.remove +})) +vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: mocks.gitProvider })) +vi.mock('./providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: mocks.fsProvider +})) + +import { connectRuntimeOwnedSshTarget } from './ephemeral-vm-runtime-ssh' + +const connection = { + type: 'ssh' as const, + projectRoot: '/workspace/repo', + target: { label: 'VM', host: 'vm', port: 22, username: 'root' } +} + +describe('connectRuntimeOwnedSshTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns the managed server a recipe VM's host runs, without waiting on relay providers", async () => { + mocks.connect.mockResolvedValue({ + targetId: 'runtime-ssh-vm-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + managedServer: { kind: 'managed', environmentId: 'env-vm' } + }) + await expect( + connectRuntimeOwnedSshTarget({ runtimeId: 'vm-1', connection }) + ).resolves.toMatchObject({ targetId: 'runtime-ssh-vm-1', environmentId: 'env-vm' }) + expect(mocks.gitProvider).not.toHaveBeenCalled() + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it('waits for the direct providers when the host has no managed server', async () => { + mocks.connect.mockResolvedValue({ + targetId: 'runtime-ssh-vm-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mocks.gitProvider.mockReturnValue({}) + mocks.fsProvider.mockReturnValue({}) + const result = await connectRuntimeOwnedSshTarget({ runtimeId: 'vm-1', connection }) + expect(result.environmentId).toBeUndefined() + expect(mocks.gitProvider).toHaveBeenCalledWith('runtime-ssh-vm-1') + }) +}) diff --git a/src/main/ephemeral-vm-runtime-ssh.ts b/src/main/ephemeral-vm-runtime-ssh.ts index 770a43cbf8f3..a0868544b481 100644 --- a/src/main/ephemeral-vm-runtime-ssh.ts +++ b/src/main/ephemeral-vm-runtime-ssh.ts @@ -14,6 +14,8 @@ const SSH_PROVIDER_READY_INTERVAL_MS = 100 export type RuntimeOwnedSshConnectionResult = { targetId: string target: SshTarget + /** The managed Orca server that serves this host; absent when it is reached directly. */ + environmentId?: string } export async function connectRuntimeOwnedSshTarget(args: { @@ -26,19 +28,25 @@ export async function connectRuntimeOwnedSshTarget(args: { throw new Error('SSH handlers are not registered.') } const target = store.upsertRuntimeOwnedTarget(args.runtimeId, args.connection.target) + let environmentId: string | undefined try { const state = await connectRegisteredSshTarget(target.id) if (state.status !== 'connected') { throw new Error(state.error || `SSH target did not connect: ${state.status}`) } - await waitForRuntimeSshProviders(target.id, args.signal) + if (state.managedServer?.kind === 'managed') { + // Its server answers for the host; no direct providers are registered for it. + environmentId = state.managedServer.environmentId + } else { + await waitForRuntimeSshProviders(target.id, args.signal) + } } catch (error) { // The target is persisted at upsert, so a failed connect/provider-wait would // orphan it; remove it (idempotent) before rethrowing so cleanup is complete. await removeRuntimeOwnedSshTarget(target.id).catch(() => undefined) throw error } - return { targetId: target.id, target } + return { targetId: target.id, target, ...(environmentId ? { environmentId } : {}) } } export async function disconnectRuntimeOwnedSshTarget(targetId: string | undefined): Promise { diff --git a/src/main/git/git-network-safety-real-git.test.ts b/src/main/git/git-network-safety-real-git.test.ts index d81c9bf087f4..db5dc805c605 100644 --- a/src/main/git/git-network-safety-real-git.test.ts +++ b/src/main/git/git-network-safety-real-git.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' import { quotePosixShell } from '../../shared/wsl-login-shell-command' -import { createGitHandlerRelay } from '../../relay/git-handler-test-harness' import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner' let root = '' @@ -73,25 +72,6 @@ async function expectIdentity(identity: string): Promise { } describe('network SSH configuration with real Git', () => { - it.each(['native', 'relay'] as const)( - '%s honors repository SSH wrappers on ordinary fetches', - async (host) => { - await git(['config', 'core.sshCommand', sshCommand('configured identity')]) - if (host === 'native') { - await expect(gitExecFileAsync(['fetch', 'origin'], { cwd: repo, env })).rejects.toThrow() - } else { - const { dispatcher, handler } = createGitHandlerRelay() - try { - await expect( - dispatcher.callRequest('git.fetch', { worktreePath: repo }) - ).rejects.toThrow() - } finally { - handler.dispose() - } - } - await expectIdentity('configured identity') - } - ) it('preserves command-line SSH configuration ahead of repository configuration', async () => { await git(['config', 'core.sshCommand', sshCommand('repository')]) @@ -104,28 +84,6 @@ describe('network SSH configuration with real Git', () => { await expectIdentity('command-line') }) - it.each(['native', 'relay'] as const)('%s preserves an explicit SSH command', async (host) => { - await git(['config', 'core.sshCommand', sshCommand('repository')]) - const command = sshCommand('environment') - if (host === 'native') { - await expect( - gitExecFileAsync(['fetch', 'origin'], { - cwd: repo, - env: { ...env, GIT_SSH_COMMAND: command } - }) - ).rejects.toThrow() - } else { - vi.stubEnv('GIT_SSH_COMMAND', command) - const { dispatcher, handler } = createGitHandlerRelay() - try { - await expect(dispatcher.callRequest('git.fetch', { worktreePath: repo })).rejects.toThrow() - } finally { - handler.dispose() - } - } - await expectIdentity('environment') - }) - it('honors configured global SSH wrappers for streaming clones from a folder', async () => { await git(['config', '--global', 'core.sshCommand', sshCommand('clone identity')]) const child = await gitSpawnAfterWindowsEnvironmentReady( diff --git a/src/main/git/source-control/blob-absence-real-git.test.ts b/src/main/git/source-control/blob-absence-real-git.test.ts index 5eb0a2bce672..0a03f574746a 100644 --- a/src/main/git/source-control/blob-absence-real-git.test.ts +++ b/src/main/git/source-control/blob-absence-real-git.test.ts @@ -1,36 +1,39 @@ -import { readFile, unlink, utimes, writeFile } from 'node:fs/promises' +import { readFile, rm, unlink, utimes, writeFile } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' -import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from '../../../relay/git-handler-test-harness' -import type { GitHandler } from '../../../relay/git-handler' import { isMissingGitBlobPath } from '../../../shared/git-blob-absence' import { readGitBlobAtIndexPath } from './git-blob-read' import { getDiff } from './file-diff' import { invalidateGitReadCaches, settledDiffCache } from './git-read-cache-invalidation' import { readWorktreeDiffStamp } from './worktree-diff-stamp' +function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' }) +} + +function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) +} + describe('real Git missing paths and failed blob reads', () => { let repo: string - let dispatcher: MockDispatcher - let handler: GitHandler beforeEach(() => { - repo = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) + repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-')) gitInit(repo) invalidateGitReadCaches() }) afterEach(async () => { - handler.dispose() invalidateGitReadCaches() - await removeGitTempDir(repo) + await rm(repo, { recursive: true, force: true }) }) async function git(args: string[], input?: string): Promise { @@ -39,75 +42,60 @@ describe('real Git missing paths and failed blob reads', () => { return result.stdout } - function diff(host: 'native' | 'relay', staged = false): Promise { - return host === 'native' - ? getDiff(repo, 'file.txt', staged) - : dispatcher.callRequest('git.diff', { worktreePath: repo, filePath: 'file.txt', staged }) + function diff(staged = false): Promise { + return getDiff(repo, 'file.txt', staged) } - it.each(['native', 'relay'] as const)( - 'does not substitute HEAD for a corrupt index blob on %s', - async (host) => { - await writeFile(path.join(repo, 'file.txt'), 'committed\n') - gitCommit(repo, 'initial') - await writeFile(path.join(repo, 'file.txt'), 'staged\n') - await git(['add', 'file.txt']) - const oid = (await git(['rev-parse', ':file.txt'])).trim() - await writeFile(path.join(repo, 'file.txt'), 'working\n') - const old = new Date(Date.now() - 10_000) - await Promise.all( - ['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old)) - ) - const before = await readWorktreeDiffStamp(repo, 'file.txt', true) - await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2))) - expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({ - exists: false, - failed: true - }) + it('does not substitute HEAD for a corrupt index blob', async () => { + await writeFile(path.join(repo, 'file.txt'), 'committed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'file.txt'), 'staged\n') + await git(['add', 'file.txt']) + const oid = (await git(['rev-parse', ':file.txt'])).trim() + await writeFile(path.join(repo, 'file.txt'), 'working\n') + const old = new Date(Date.now() - 10_000) + await Promise.all( + ['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old)) + ) + const before = await readWorktreeDiffStamp(repo, 'file.txt', true) + await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2))) + expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({ + exists: false, + failed: true + }) - expect(await diff(host)).toMatchObject({ originalContent: '', modifiedContent: 'working\n' }) - if (host === 'native') { - expect(settledDiffCache.stats().entries).toBe(0) - } - await git(['hash-object', '-w', '--stdin'], 'staged\n') - const after = await readWorktreeDiffStamp(repo, 'file.txt', true) - expect(after?.value).toBe(before?.value) + expect(await diff()).toMatchObject({ originalContent: '', modifiedContent: 'working\n' }) + expect(settledDiffCache.stats().entries).toBe(0) + await git(['hash-object', '-w', '--stdin'], 'staged\n') + const after = await readWorktreeDiffStamp(repo, 'file.txt', true) + expect(after?.value).toBe(before?.value) - expect(await diff(host)).toMatchObject({ - originalContent: 'staged\n', - modifiedContent: 'working\n' - }) - if (host === 'native') { - expect(settledDiffCache.stats().entries).toBe(1) - } - } - ) + expect(await diff()).toMatchObject({ + originalContent: 'staged\n', + modifiedContent: 'working\n' + }) + expect(settledDiffCache.stats().entries).toBe(1) + }) - it.each(['native', 'relay'] as const)( - 'retains added paths on an unborn branch on %s', - async (host) => { - await writeFile(path.join(repo, 'file.txt'), 'new file\n') - await git(['add', 'file.txt']) - expect(await diff(host, true)).toMatchObject({ - originalContent: '', - modifiedContent: 'new file\n' - }) - } - ) + it('retains added paths on an unborn branch', async () => { + await writeFile(path.join(repo, 'file.txt'), 'new file\n') + await git(['add', 'file.txt']) + expect(await diff(true)).toMatchObject({ + originalContent: '', + modifiedContent: 'new file\n' + }) + }) - it.each(['native', 'relay'] as const)( - 'retains additions absent from a valid HEAD tree on %s', - async (host) => { - await writeFile(path.join(repo, 'seed.txt'), 'seed\n') - gitCommit(repo, 'initial') - await writeFile(path.join(repo, 'file.txt'), 'new file\n') - await git(['add', 'file.txt']) - expect(await diff(host, true)).toMatchObject({ - originalContent: '', - modifiedContent: 'new file\n' - }) - } - ) + it('retains additions absent from a valid HEAD tree', async () => { + await writeFile(path.join(repo, 'seed.txt'), 'seed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'file.txt'), 'new file\n') + await git(['add', 'file.txt']) + expect(await diff(true)).toMatchObject({ + originalContent: '', + modifiedContent: 'new file\n' + }) + }) it('recognizes actual index and tree absence without accepting corrupt objects', async () => { await writeFile(path.join(repo, 'seed.txt'), 'seed\n') diff --git a/src/main/git/source-control/bulk-pathspec-stdin.test.ts b/src/main/git/source-control/bulk-pathspec-stdin.test.ts deleted file mode 100644 index c45f82645f04..000000000000 --- a/src/main/git/source-control/bulk-pathspec-stdin.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises' -import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from '../../../relay/git-handler-test-harness' -import type { GitHandler } from '../../../relay/git-handler' -import { bulkStageFiles, bulkUnstageFiles } from './staging' -import { bulkDiscardChanges } from './discard-changes' - -describe('bulk pathspec stdin on execution hosts', () => { - let repo: string - let dispatcher: MockDispatcher - let handler: GitHandler - - beforeEach(() => { - repo = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) - gitInit(repo) - }) - - afterEach(async () => { - handler.dispose() - await removeGitTempDir(repo) - }) - - async function changedFiles(staged: boolean): Promise { - const result = await runProcess({ - program: 'git', - args: ['diff', ...(staged ? ['--cached'] : []), '--name-only', '-z'], - cwd: repo - }) - expect(result.code).toBe(0) - return result.stdout.split('\0').filter(Boolean).sort() - } - - function mutate( - host: 'native' | 'relay', - action: 'stage' | 'unstage' | 'discard', - filePaths: string[] - ): Promise { - if (host === 'relay') { - const methods = { - stage: 'git.bulkStage', - unstage: 'git.bulkUnstage', - discard: 'git.bulkDiscard' - } - return dispatcher.callRequest(methods[action], { worktreePath: repo, filePaths }) - } - const operations = { - stage: bulkStageFiles, - unstage: bulkUnstageFiles, - discard: bulkDiscardChanges - } - return operations[action](repo, filePaths) - } - - it.each(['native', 'relay'] as const)( - 'stages, unstages and discards over 100 literal paths on %s', - async (host) => { - const selected = Array.from({ length: 105 }, (_, index) => `file-${index}.txt`) - selected.push('[k]eep.log', 'space name.txt', '-option.txt') - if (process.platform !== 'win32') { - selected.push('line\nname.txt', ':(magic).txt', 'back\\slash.txt') - } - const allPaths = [...selected, 'keep.log'] - await Promise.all( - allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'original\n')) - ) - gitCommit(repo, 'initial') - await Promise.all( - allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'modified\n')) - ) - - await mutate(host, 'stage', []) - expect(await changedFiles(true)).toEqual([]) - await mutate(host, 'stage', selected) - expect(await changedFiles(true)).toEqual([...selected].sort()) - await mutate(host, 'unstage', []) - expect(await changedFiles(true)).toEqual([...selected].sort()) - await mutate(host, 'unstage', selected) - expect(await changedFiles(true)).toEqual([]) - await mutate(host, 'discard', []) - expect(await changedFiles(false)).toEqual([...allPaths].sort()) - await mutate(host, 'discard', selected) - expect(await changedFiles(false)).toEqual(['keep.log']) - expect(await readFile(path.join(repo, 'keep.log'), 'utf8')).toBe('modified\n') - } - ) - - it.each(['native', 'relay'] as const)('rejects NUL path injection on %s', async (host) => { - await writeFile(path.join(repo, 'one.txt'), 'original\n') - gitCommit(repo, 'initial') - await writeFile(path.join(repo, 'one.txt'), 'modified\n') - - await expect(mutate(host, 'stage', ['one.txt\0:(top)**'])).rejects.toThrow('NUL') - expect(await changedFiles(true)).toEqual([]) - }) -}) diff --git a/src/main/git/source-control/staging-discard-index-safety.test.ts b/src/main/git/source-control/staging-discard-index-safety.test.ts index aaff540d2310..7dbda405cba2 100644 --- a/src/main/git/source-control/staging-discard-index-safety.test.ts +++ b/src/main/git/source-control/staging-discard-index-safety.test.ts @@ -1,38 +1,36 @@ -import { readFile, writeFile } from 'node:fs/promises' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' -import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' -import { - createGitHandlerRelay, - createGitTempDir, - removeGitTempDir -} from '../../../relay/git-handler-test-harness' -import type { GitHandler } from '../../../relay/git-handler' import { bulkUnstageFiles, unstageFile } from './staging' import { bulkDiscardChanges, discardChanges } from './discard-changes' -const modes = [ - { host: 'native', bulk: false }, - { host: 'native', bulk: true }, - { host: 'relay', bulk: false }, - { host: 'relay', bulk: true } -] as const +function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' }) +} + +function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) +} + +const modes = [{ bulk: false }, { bulk: true }] as const describe('staging and discard preserve index authority', () => { let repo: string - let dispatcher: MockDispatcher - let handler: GitHandler beforeEach(() => { - repo = createGitTempDir() - ;({ dispatcher, handler } = createGitHandlerRelay()) + repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-')) gitInit(repo) }) afterEach(async () => { - handler.dispose() - await removeGitTempDir(repo) + await rm(repo, { recursive: true, force: true }) }) async function git(args: string[], input?: string): Promise { @@ -46,19 +44,13 @@ describe('staging and discard preserve index authority', () => { action: 'unstage' | 'discard', filePaths: string[] ): Promise { - if (mode.host === 'relay') { - return dispatcher.callRequest( - `git.${mode.bulk ? (action === 'unstage' ? 'bulkUnstage' : 'bulkDiscard') : action}`, - { worktreePath: repo, ...(mode.bulk ? { filePaths } : { filePath: filePaths[0] }) } - ) - } if (mode.bulk) { return (action === 'unstage' ? bulkUnstageFiles : bulkDiscardChanges)(repo, filePaths) } return (action === 'unstage' ? unstageFile : discardChanges)(repo, filePaths[0]) } - it.each(modes)('preserves staged MM content during $host bulk=$bulk discard', async (mode) => { + it.each(modes)('preserves staged MM content during bulk=$bulk discard', async (mode) => { await writeFile(path.join(repo, 'file.txt'), 'committed\n') gitCommit(repo, 'initial') await writeFile(path.join(repo, 'file.txt'), 'staged\n') @@ -73,7 +65,7 @@ describe('staging and discard preserve index authority', () => { expect(await git(['diff', '--cached', '--name-only'])).toBe('file.txt\n') }) - it.each(modes)('restores staged additions during $host bulk=$bulk AM discard', async (mode) => { + it.each(modes)('restores staged additions during bulk=$bulk AM discard', async (mode) => { await writeFile(path.join(repo, 'initial.txt'), 'committed\n') gitCommit(repo, 'initial') await writeFile(path.join(repo, 'new.txt'), 'staged addition\n') @@ -87,7 +79,7 @@ describe('staging and discard preserve index authority', () => { expect(await git(['diff', '--name-only'])).toBe('') }) - it.each(modes)('unstages selected unborn paths on $host bulk=$bulk', async (mode) => { + it.each(modes)('unstages selected unborn paths on bulk=$bulk', async (mode) => { await writeFile(path.join(repo, 'selected.txt'), 'selected\n') await writeFile(path.join(repo, 'keep.txt'), 'keep\n') await git(['add', '.']) @@ -98,7 +90,7 @@ describe('staging and discard preserve index authority', () => { expect(await readFile(path.join(repo, 'selected.txt'), 'utf8')).toBe('selected\n') }) - it.each(['native', 'relay'] as const)('unstages both rename paths on %s', async (host) => { + it('unstages both rename paths', async () => { await writeFile(path.join(repo, 'old.txt'), 'rename content\n') await writeFile(path.join(repo, 'keep.txt'), 'keep\n') gitCommit(repo, 'initial') @@ -106,32 +98,29 @@ describe('staging and discard preserve index authority', () => { await writeFile(path.join(repo, 'keep.txt'), 'keep staged\n') await git(['add', 'keep.txt']) - await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt']) + await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt']) expect(await git(['diff', '--cached', '--name-only'])).toBe('keep.txt\n') expect(await git(['ls-files', '-z'])).toBe('keep.txt\0old.txt\0') expect(await readFile(path.join(repo, 'new.txt'), 'utf8')).toBe('rename content\n') }) - it.each(['native', 'relay'] as const)( - 'discards a staged rename after unstaging both paths on %s', - async (host) => { - await writeFile(path.join(repo, 'old.txt'), 'rename content\n') - gitCommit(repo, 'initial') - await git(['mv', 'old.txt', 'new.txt']) - - await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt']) - await mutate({ host, bulk: true }, 'discard', ['new.txt', 'old.txt']) - - expect(await git(['status', '--porcelain'])).toBe('') - expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n') - await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({ - code: 'ENOENT' - }) - } - ) + it('discards a staged rename after unstaging both paths', async () => { + await writeFile(path.join(repo, 'old.txt'), 'rename content\n') + gitCommit(repo, 'initial') + await git(['mv', 'old.txt', 'new.txt']) + + await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt']) + await mutate({ bulk: true }, 'discard', ['new.txt', 'old.txt']) + + expect(await git(['status', '--porcelain'])).toBe('') + expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n') + await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) - it.each(modes)('rejects an unresolved conflict on $host bulk=$bulk', async (mode) => { + it.each(modes)('rejects an unresolved conflict on bulk=$bulk', async (mode) => { await writeFile(path.join(repo, 'file.txt'), 'base\n') gitCommit(repo, 'initial') const blobs = await Promise.all( diff --git a/src/main/git/status-branch-compare-real-ref.test.ts b/src/main/git/status-branch-compare-real-ref.test.ts index 561fe5fab7ae..3dc8e47dabff 100644 --- a/src/main/git/status-branch-compare-real-ref.test.ts +++ b/src/main/git/status-branch-compare-real-ref.test.ts @@ -4,8 +4,6 @@ import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getBranchCompare } from './status' -import { branchCompare } from '../../relay/git-handler-ops' -import { gitChangeListArgs, parseGitChangeList } from '../../shared/git-change-list' const tempRoots: string[] = [] @@ -17,22 +15,12 @@ function git(repo: string, args: string[]): string { }).trim() } -function relayCompare(repo: string, baseRef: string) { - return branchCompare( - async (args, cwd) => ({ stdout: git(cwd, args), stderr: '' }), - repo, - baseRef, - async (mergeBase, headOid) => - parseGitChangeList(git(repo, gitChangeListArgs(mergeBase, headOid))) - ) -} - afterEach(async () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) describe('getBranchCompare real refs', () => { - it('reports equal commits as empty on native and relay despite staged and unstaged changes', async () => { + it('reports equal commits as empty despite staged and unstaged changes', async () => { const repo = await mkdtemp(path.join(tmpdir(), 'orca-equal-branch-compare-')) tempRoots.push(repo) git(repo, ['init', '-q']) @@ -46,37 +34,18 @@ describe('getBranchCompare real refs', () => { git(repo, ['add', 'changes.txt']) await writeFile(path.join(repo, 'changes.txt'), 'unstaged\n') - for (const result of await Promise.all([ - getBranchCompare(repo, 'base'), - relayCompare(repo, 'base') - ])) { - expect(result).toEqual({ - summary: { - baseRef: 'base', - baseOid: oid, - compareRef: 'feature', - headOid: oid, - mergeBase: oid, - changedFiles: 0, - commitsAhead: 0, - commitsBehind: 0, - status: 'ready' - }, - entries: [] - }) - } - }) - - it('keeps the merge-base failure for identical blob tips on the relay', async () => { - const repo = await mkdtemp(path.join(tmpdir(), 'orca-blob-branch-compare-')) - tempRoots.push(repo) - git(repo, ['init', '-q']) - await writeFile(path.join(repo, 'blob.txt'), 'not a commit\n') - const oid = git(repo, ['hash-object', '-w', 'blob.txt']) - await writeFile(path.join(repo, '.git', 'HEAD'), `${oid}\n`) - - await expect(relayCompare(repo, oid)).resolves.toMatchObject({ - summary: { headOid: oid, baseOid: oid, status: 'no-merge-base', mergeBase: null }, + expect(await getBranchCompare(repo, 'base')).toEqual({ + summary: { + baseRef: 'base', + baseOid: oid, + compareRef: 'feature', + headOid: oid, + mergeBase: oid, + changedFiles: 0, + commitsAhead: 0, + commitsBehind: 0, + status: 'ready' + }, entries: [] }) }) @@ -109,19 +78,14 @@ describe('getBranchCompare real refs', () => { ]) expect(rawOid).not.toBe(peeledOid) - for (const result of await Promise.all([ - getBranchCompare(client, 'origin/tagbase'), - relayCompare(client, 'origin/tagbase') - ])) { - expect(result.summary).toMatchObject({ - baseOid: rawOid, - headOid: peeledOid, - mergeBase: peeledOid, - changedFiles: 0, - commitsAhead: 0, - commitsBehind: 0, - status: 'ready' - }) - } + expect((await getBranchCompare(client, 'origin/tagbase')).summary).toMatchObject({ + baseOid: rawOid, + headOid: peeledOid, + mergeBase: peeledOid, + changedFiles: 0, + commitsAhead: 0, + commitsBehind: 0, + status: 'ready' + }) }) }) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts deleted file mode 100644 index c62d419a25f7..000000000000 --- a/src/main/git/status-branch-line-total-relay-parity.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Main and relay must publish the same `branchLineTotal` for the same fixture - * repo. Both call sites - * share `src/shared/git-branch-line-total.ts`; this is the test that catches one - * of them wiring it up differently — different flags, a different untracked - * source, a different completeness gate. - */ -import { execFile, execFileSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { promisify } from 'node:util' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getStatus } from './status' -import type { GitExec } from '../../relay/git-handler-ops' -import { getStatusOp } from '../../relay/git-handler-status-ops' -import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' -import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' -import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache' - -const execFileAsync = promisify(execFile) - -// Fork point → working tree for the fixture below: -// tracked.txt +2 (branch commit only) -// partial.txt +1 (staged +2 then one of those lines removed unstaged) -// flip.txt 0 (added in the branch commit, removed again in the worktree) -// moved.txt 0 (pure rename) -// fresh.txt +3 (untracked) -// No fixture path here looks like test or generated code, so it is all source. -const EXPECTED_TOTAL = { - added: 6, - removed: 0, - test: { added: 0, removed: 0 }, - generated: { added: 0, removed: 0 } -} -// Summing the per-area status rows instead would give this — the wrong answer -// the shared module exists to avoid. -const AREA_ROW_SUM = { added: 5, removed: 2 } - -const relayGit: GitExec = async (args, cwd, opts) => { - const { stdout, stderr } = await execFileAsync('git', args, { - cwd, - encoding: 'utf8', - ...(opts?.signal ? { signal: opts.signal } : {}), - ...(opts?.timeout ? { timeout: opts.timeout } : {}) - }) - return { stdout, stderr } -} - -const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => { - const { stdout } = await relayGit(args, cwd, { - disableOptionalLocks: options.disableOptionalLocks, - signal: options.signal - }) - return { stoppedEarly: options.onStdout(stdout) === true } -} - -function runFixtureGit(repo: string, args: string[]): string { - return execFileSync( - 'git', - [ - '-c', - 'user.email=test@test.com', - '-c', - 'user.name=Test', - '-c', - 'commit.gpgSign=false', - ...args - ], - { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } - ).trim() -} - -/** Returns the merge-base OID the chip is measured against. */ -async function seedParityFixture(repo: string): Promise { - execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) - await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n') - await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n') - await writeFile(path.join(repo, 'renamed.txt'), 'stable\n') - await writeFile(path.join(repo, 'flip.txt'), 'p\n') - runFixtureGit(repo, ['add', '.']) - runFixtureGit(repo, ['commit', '-m', 'base']) - const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) - - runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) - await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n') - await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n') - runFixtureGit(repo, ['add', '-A']) - runFixtureGit(repo, ['commit', '-m', 'branch commit']) - - runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt']) - // Staged and unstaged hunks land on the same added lines, so an area sum - // double-counts them. - await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n') - runFixtureGit(repo, ['add', 'partial.txt']) - await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n') - // The branch commit's line, taken back out in the worktree: net zero. - await writeFile(path.join(repo, 'flip.txt'), 'p\n') - await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n') - return mergeBase -} - -function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): { - added: number - removed: number -} { - let added = 0 - let removed = 0 - for (const entry of entries) { - added += entry.added ?? 0 - removed += entry.removed ?? 0 - } - return { added, removed } -} - -describe('branch line total parity between main and relay', () => { - let repo: string - - beforeEach(async () => { - clearGitStatusLineStatsCache() - invalidateGitBranchLineTotalInFlight() - repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-')) - }) - - afterEach(async () => { - clearGitStatusLineStatsCache() - invalidateGitBranchLineTotalInFlight() - await rm(repo, { recursive: true, force: true }) - }) - - it('produces identical totals for the same fixture repo', async () => { - const mergeBase = await seedParityFixture(repo) - - const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - clearGitStatusLineStatsCache() - const relayStatus = await getStatusOp(relayGit, relayStreamGit, { - worktreePath: repo, - branchLineTotalMergeBase: mergeBase - }) - - expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase }) - expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) - // Both sides model the same worktree, so a parity pass on a mismatched - // entry list would be meaningless. - expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual( - mainStatus.entries.map((entry) => entry.path).sort() - ) - }) - - it('agrees on a number no per-area row sum could produce', async () => { - const mergeBase = await seedParityFixture(repo) - - const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - clearGitStatusLineStatsCache() - const relayStatus = await getStatusOp(relayGit, relayStreamGit, { - worktreePath: repo, - branchLineTotalMergeBase: mergeBase - }) - - expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM) - expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual( - AREA_ROW_SUM - ) - expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM) - expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) - }) - - it('omits the total on both sides when no merge base is requested', async () => { - await seedParityFixture(repo) - - const mainStatus = await getStatus(repo) - clearGitStatusLineStatsCache() - const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo }) - - expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false) - expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false) - }) -}) diff --git a/src/main/git/status-conflict-overlap.bench.test.ts b/src/main/git/status-conflict-overlap.bench.test.ts deleted file mode 100644 index 5c92a23e10c0..000000000000 --- a/src/main/git/status-conflict-overlap.bench.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { performance } from 'node:perf_hooks' -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFsPromises from 'node:fs/promises' -import type * as GitRunner from './runner' -import type { GitExec } from '../../relay/git-handler-ops' -import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' - -const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({ - gitStreamStdoutMock: vi.fn(), - readFileMock: vi.fn() -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, readFile: readFileMock } -}) - -vi.mock('./runner', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, gitStreamStdout: gitStreamStdoutMock } -}) - -import { getStatus } from './status' -import { getStatusOp } from '../../relay/git-handler-status-ops' - -const BENCH_DELAY_MS = 25 -const BENCH_SAMPLES = 31 -const BENCH_WARMUPS = 5 -const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip - -type Deferred = { - promise: Promise - resolve: (value: T) => void -} - -type BenchmarkResult = { - path: string - medianMs: number - p95Ms: number - minMs: number - maxMs: number -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve } -} - -function wait(delayMs: number): Promise { - return new Promise((resolve) => setTimeout(resolve, delayMs)) -} - -function percentile(samples: number[], fraction: number): number { - const sorted = [...samples].sort((a, b) => a - b) - return sorted[Math.ceil(sorted.length * fraction) - 1]! -} - -describe('git status conflict-read overlap', () => { - const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) - - beforeEach(() => { - readFileMock.mockReset() - gitStreamStdoutMock.mockReset() - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false }) - }) - - it('starts native status before conflict-marker I/O settles', async () => { - const markerRead = deferred() - const statusStarted = deferred() - readFileMock.mockReturnValue(markerRead.promise) - gitStreamStdoutMock.mockImplementation(async () => { - statusStarted.resolve() - return { stoppedEarly: false } - }) - - const resultPromise = getStatus('/repo') - await statusStarted.promise - expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') - - markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') - await expect(resultPromise).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it('starts relay status before conflict-marker I/O settles', async () => { - const markerRead = deferred() - const statusStarted = deferred() - readFileMock.mockReturnValue(markerRead.promise) - const relayStreamGit: RelayGitStreamExec = async () => { - statusStarted.resolve() - return { stoppedEarly: false } - } - - const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) - await statusStarted.promise - expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') - - markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') - await expect(resultPromise).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it('observes an early native status rejection while marker I/O remains pending', async () => { - const markerRead = deferred() - readFileMock.mockReturnValue(markerRead.promise) - gitStreamStdoutMock.mockRejectedValue(new Error('status failed first')) - let settled = false - - const resultPromise = getStatus('/repo').finally(() => { - settled = true - }) - await Promise.resolve() - await Promise.resolve() - expect(settled).toBe(false) - - markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') - await expect(resultPromise).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it('keeps a synchronous native status failure fail-soft', async () => { - const statusError = new Error('status threw') - gitStreamStdoutMock.mockImplementation(() => { - throw statusError - }) - - await expect(getStatus('/repo')).resolves.toMatchObject({ - entries: [], - conflictOperation: 'unknown' - }) - }) - - it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => { - const statusError = new Error(`status ${mode}`) - const relayStreamGit = (() => { - if (mode === 'throw') { - throw statusError - } - return Promise.reject(statusError) - }) as RelayGitStreamExec - - await expect( - getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) - ).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' }) - }) - - it('rethrows the original relay status failure after cancellation', async () => { - const controller = new AbortController() - const statusError = new Error('cancelled status') - const relayStreamGit: RelayGitStreamExec = async () => { - controller.abort(statusError) - throw statusError - } - - await expect( - getStatusOp( - relayGit, - relayStreamGit, - { worktreePath: '/repo' }, - { signal: controller.signal } - ) - ).rejects.toBe(statusError) - }) - - it('surfaces detector errors without waiting for a hung status read', async () => { - const statusStarted = deferred() - const relayStreamGit: RelayGitStreamExec = () => { - statusStarted.resolve() - return new Promise(() => {}) - } - const invalidPath = { toString: () => '/repo' } - - const resultPromise = getStatusOp(relayGit, relayStreamGit, { - worktreePath: invalidPath - }) - const rejection = expect(resultPromise).rejects.toThrow(TypeError) - await statusStarted.promise - await rejection - }) - - it('keeps detector errors ahead of concurrent status failures', async () => { - const statusError = new Error('status failed too') - const relayStreamGit = (() => { - throw statusError - }) as RelayGitStreamExec - const invalidPath = { toString: () => '/repo' } - - const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath }) - await expect(result).rejects.toBeInstanceOf(TypeError) - }) -}) - -describeBench('git status conflict-read overlap benchmark', () => { - it('measures native and relay orchestration with matched independent latency', async () => { - readFileMock.mockImplementation(async () => { - await wait(BENCH_DELAY_MS) - return 'gitdir: /repo/.git/worktrees/feature\n' - }) - gitStreamStdoutMock.mockImplementation(async () => { - await wait(BENCH_DELAY_MS) - return { stoppedEarly: false } - }) - const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) - const relayStreamGit: RelayGitStreamExec = async () => { - await wait(BENCH_DELAY_MS) - return { stoppedEarly: false } - } - const cases = [ - { name: 'native', run: () => getStatus('/repo') }, - { - name: 'relay', - run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) - } - ] - - const results: BenchmarkResult[] = [] - for (const benchmarkCase of cases) { - for (let index = 0; index < BENCH_WARMUPS; index += 1) { - await benchmarkCase.run() - } - const samples: number[] = [] - for (let index = 0; index < BENCH_SAMPLES; index += 1) { - const startedAt = performance.now() - await benchmarkCase.run() - samples.push(performance.now() - startedAt) - } - results.push({ - path: benchmarkCase.name, - medianMs: Number(percentile(samples, 0.5).toFixed(3)), - p95Ms: Number(percentile(samples, 0.95).toFixed(3)), - minMs: Number(Math.min(...samples).toFixed(3)), - maxMs: Number(Math.max(...samples).toFixed(3)) - }) - } - - console.table(results) - expect(results).toHaveLength(2) - }, 10_000) -}) diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 2e4198eb6658..5b23d7877b39 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -253,7 +253,6 @@ async function performAddWorktree( await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options) } - // SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep. // Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain // `git push` create+set origin/ (git >=2.37; older clients ignore it). `--local` on a // linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent, diff --git a/src/main/git/worktree-rebase-update-refs-real-git.test.ts b/src/main/git/worktree-rebase-update-refs-real-git.test.ts index 2795f3f1a7e6..8da9da2388b3 100644 --- a/src/main/git/worktree-rebase-update-refs-real-git.test.ts +++ b/src/main/git/worktree-rebase-update-refs-real-git.test.ts @@ -10,7 +10,6 @@ vi.mock('./local-repo-ref-maintenance', () => ({ })) import { forceDeleteLocalBranch } from './worktree-branch-removal' -import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup' const image = process.env.ORCA_GIT_COMPAT_IMAGE const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git' @@ -82,10 +81,10 @@ afterEach(async () => { }) describe('rebase branch reservations with real Git', () => { - it.for(['native', 'relay'] as const)( + it.for(['native'] as const)( '%s retains auxiliary branches reserved by a paused real rebase --update-refs', { timeout: 120_000 }, - async (host, context) => { + async (_host, context) => { await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) await writeFile(join(checkout, 'first'), 'first\n') await git(['add', 'first'], checkout) @@ -117,10 +116,7 @@ describe('rebase branch reservations with real Git', () => { /checked out|used by worktree/ ) const cleanupGit = vi.fn((args: string[], cwd: string) => git(args, cwd)) - const remove = () => - host === 'native' - ? forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit) - : forceDeletePreservedRelayBranch(cleanupGit, repo, 'auxiliary', expected) + const remove = () => forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit) await expect(remove()).rejects.toThrow('checked out in another worktree') expect(cleanupGit.mock.calls.map(([args]) => args)).toEqual([ ['worktree', 'list', '--porcelain'] diff --git a/src/main/git/worktree-safety-real-git.test.ts b/src/main/git/worktree-safety-real-git.test.ts index 7a31e2dfc5f1..d6a8e2ce4ea6 100644 --- a/src/main/git/worktree-safety-real-git.test.ts +++ b/src/main/git/worktree-safety-real-git.test.ts @@ -1,14 +1,9 @@ import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { GitCapabilityCache } from '../../shared/git-capability-cache' -import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation' -import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' -import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin' -import type { GitExec } from '../../relay/git-handler-ops' const { runner } = vi.hoisted(() => ({ runner: vi.fn() })) vi.mock('./runner', () => ({ @@ -18,14 +13,8 @@ vi.mock('./runner', () => ({ })) vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() })) -import { removeWorktree } from './worktree-removal' -import { forceDeleteLocalBranch } from './worktree-branch-removal' -import { readWorktreeList } from './worktree-list-reader' import { getBranchConflictKind, getBranchConflictKindViaExec } from './repo-branch-conflict' import { clearGitCapabilityStateForTests } from './git-capability-state' -import { removeWorktreeOp } from '../../relay/git-handler-worktree-remove' -import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup' -import { readRelayWorktreeList } from '../../relay/git-handler-worktree-list' const execFileAsync = promisify(execFile) const image = process.env.ORCA_GIT_COMPAT_IMAGE @@ -37,7 +26,6 @@ const dockerUser = : [] let root = '' let repo = '' -let checkout = '' async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> { return image @@ -66,8 +54,6 @@ async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr }) } -const relay: GitExec = (args, cwd) => git(args, cwd) - async function initializeRepo(cwd: string): Promise { await mkdir(cwd, { recursive: true }) await git(['init', '-q', '-b', 'main'], cwd).catch(() => git(['init', '-q'], cwd)) @@ -92,7 +78,6 @@ beforeAll(async () => { beforeEach(async () => { root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-'))) repo = join(root, 'repo') - checkout = join(root, 'checkout') await initializeRepo(repo) clearGitCapabilityStateForTests() runner.mockImplementation((args: string[], options: { cwd?: string }) => git(args, options.cwd)) @@ -103,99 +88,6 @@ afterEach(async () => { }) describe('worktree safety with the real Git binary', () => { - it.each(['native', 'relay'] as const)( - '%s preserves unpublished submodule objects without implicit force', - async (host) => { - const origin = join(root, 'sub-origin') - await initializeRepo(origin) - await git(['-c', 'protocol.file.allow=always', 'submodule', 'add', origin, 'sub']) - await git(['commit', '-qam', 'submodule']) - await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) - await git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], checkout) - const sub = join(checkout, 'sub') - await git(['checkout', '-qb', 'unpublished'], sub) - await writeFile(join(sub, 'local'), 'unpublished work\n') - await git(['add', 'local'], sub) - await git( - [ - '-c', - 'user.name=Safety', - '-c', - 'user.email=safety@example.invalid', - '-c', - 'commit.gpgSign=false', - 'commit', - '-qm', - 'local' - ], - sub - ) - const local = (await git(['rev-parse', 'HEAD'], sub)).stdout.trim() - await git(['checkout', '--detach', 'HEAD~1'], sub) - expect( - (await git(['status', '--porcelain', '--ignore-submodules=none'], checkout)).stdout - ).toBe('') - - const remove = (force: boolean) => - host === 'native' - ? removeWorktree(repo, checkout, force, { - deleteBranch: false, - knownRemovedWorktree: { branch: 'refs/heads/feature', head: '', locked: false } - }) - : removeWorktreeOp( - relay, - { worktreePath: checkout, force, deleteBranch: false }, - new GitCapabilityCache() - ) - await expect(remove(false)).rejects.toThrow(/submodules cannot be moved or removed/) - expect((await git(['cat-file', '-t', local], sub)).stdout.trim()).toBe('commit') - expect((await git(['worktree', 'list', '--porcelain'])).stdout).toContain(checkout) - await expect(remove(true)).resolves.toEqual({}) - await expect(readFile(join(checkout, '.git'))).rejects.toThrow() - }, - 120_000 - ) - - it.each(['native', 'relay'] as const)( - '%s reads preparation ownership from admin metadata on old porcelain', - async (host) => { - await git(['worktree', 'add', '-q', '--detach', checkout]) - const reason = 'orca-create-preparation:v1:12345:retained' - await git(['worktree', 'lock', '--reason', reason, checkout]) - const oldList = (await git(['worktree', 'list', '--porcelain'])).stdout - .split('\n') - .filter((line) => !line.startsWith('locked')) - .join('\n') - const capabilities = new GitCapabilityCache() - capabilities.rememberUnsupported('worktree-list-z') - runner.mockImplementation(async (args: string[], options: { cwd?: string }) => { - if (args[0] === 'worktree' && args[1] === 'list') { - if (args.includes('-z')) { - throw Object.assign(new Error("unknown switch `z'"), { - code: 129, - stderr: "error: unknown switch `z'" - }) - } - return { stdout: oldList, stderr: '' } - } - return git(args, options.cwd) - }) - const entries = - host === 'native' - ? await readWorktreeList(repo) - : await readRelayWorktreeList((args, cwd) => runner(args, { cwd }), repo, capabilities) - const prepared = entries.find((entry) => entry.path === checkout) - expect(prepared).toMatchObject({ locked: true, lockReason: reason }) - expect(prepared && isWorktreeCreatePreparation(prepared)).toBe(true) - await rm(checkout, { recursive: true }) - const annotated = await annotateWorktreeLocksFromAdmin(repo, parseWorktreeList(oldList)) - expect(annotated.find((entry) => entry.path === checkout)).toMatchObject({ - locked: true, - lockReason: reason - }) - }, - 90_000 - ) it.each([ ['feature', 'Feature'], @@ -226,59 +118,4 @@ describe('worktree safety with the real Git binary', () => { 90_000 ) - it.each(['native', 'relay'] as const)( - '%s retains detached branches reserved by rebase or bisect', - async (host) => { - await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) - const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim() - await git(['checkout', '--detach'], checkout) - const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim() - const remove = () => - host === 'native' - ? forceDeleteLocalBranch(repo, 'feature', expected, git) - : forceDeletePreservedRelayBranch(relay, repo, 'feature', expected) - for (const marker of ['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START']) { - const file = join(gitDir, ...marker.split('/')) - await mkdir(join(file, '..'), { recursive: true }) - await writeFile(file, marker === 'BISECT_START' ? 'feature\n' : 'refs/heads/feature\n') - if (marker === 'BISECT_START') { - await writeFile(join(gitDir, 'BISECT_LOG'), '') - } - await expect(git(['branch', '-D', 'feature'])).rejects.toThrow( - /checked out|used by worktree/ - ) - await expect(remove()).rejects.toThrow('checked out in another worktree') - expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected) - await rm(file) - if (marker === 'BISECT_START') { - await rm(join(gitDir, 'BISECT_LOG')) - } - } - await expect(remove()).resolves.toBeUndefined() - }, - 120_000 - ) - it.each(['native', 'relay'] as const)( - '%s restores a deleted ref when detached branch use starts during deletion', - async (host) => { - await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) - const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim() - await git(['checkout', '--detach'], checkout) - const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim() - const racingGit = async (args: string[], cwd: string) => { - const result = await git(args, cwd) - if (args[0] === 'update-ref' && args[1] === '-d') { - await writeFile(join(gitDir, 'BISECT_START'), 'feature\n') - } - return result - } - const remove = () => - host === 'native' - ? forceDeleteLocalBranch(repo, 'feature', expected, racingGit) - : forceDeletePreservedRelayBranch(racingGit, repo, 'feature', expected) - await expect(remove()).rejects.toThrow('checked out in another worktree') - expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected) - }, - 90_000 - ) }) diff --git a/src/main/github/client-merge-queue-auto-merge.test.ts b/src/main/github/client-merge-queue-auto-merge.test.ts index b6fe0d535d50..f0d62ff75c6b 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -110,7 +110,7 @@ describe('GitHub GraphQL rate-limit guard', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root', host: 'github.com' } ) diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts index 82167d8bdda0..3db1e981ed11 100644 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ b/src/main/github/client-pr-branch-discovery.test.ts @@ -188,7 +188,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index 2c63f4ddc016..397a22fa1b5f 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -126,7 +126,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -162,7 +162,7 @@ describe('getPRForBranch', () => { '--limit', '1', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -308,7 +308,7 @@ describe('getPRForBranch', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -381,7 +381,7 @@ describe('getPRForBranch', () => { '--limit', '1', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -507,7 +507,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client-pr-linked-lookup.test.ts b/src/main/github/client-pr-linked-lookup.test.ts index 36b2d0df0ca1..a7ce6cce203e 100644 --- a/src/main/github/client-pr-linked-lookup.test.ts +++ b/src/main/github/client-pr-linked-lookup.test.ts @@ -80,7 +80,7 @@ describe('getPRForBranch', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -93,7 +93,7 @@ describe('getPRForBranch', () => { '--repo', 'fork/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -132,7 +132,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -411,7 +411,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -502,7 +502,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -543,7 +543,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -579,7 +579,7 @@ describe('getPRForBranch', () => { 'view', '77', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/non-github-repo' } ) @@ -618,7 +618,7 @@ describe('getPRForBranch', () => { 'view', 'feature/test', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/non-github-repo' } ) @@ -658,7 +658,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts new file mode 100644 index 000000000000..e92dfc76c48d --- /dev/null +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -0,0 +1,516 @@ +// #26948: tracking a base branch must not attach its integration PR to a feature branch. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' + +const { clientMocks, moduleMocks } = await vi.hoisted(async () => { + const moduleMocks = await import('./client-test-mocks') + return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks } +}) + +vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) +vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) +vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) +vi.mock('./local-git-config-signature', () => + moduleMocks.localGitConfigSignatureModuleMock(clientMocks) +) +vi.mock('./github-enterprise-repository', async (importOriginal) => + moduleMocks.githubEnterpriseRepositoryModuleMock( + await importOriginal() + ) +) +vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks)) +vi.mock('./github-api-repository', async (importOriginal) => + moduleMocks.githubApiRepositoryModuleMock( + clientMocks, + await importOriginal() + ) +) + +import { getPRForBranch } from './client/lookup/get-pr-for-branch' +import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' +import { resetPRForBranchMocks } from './client-test-harness' +import type { RestPullRequest } from './client/lookup/pull-request-lookup-data' +import type { OwnerRepo } from './gh-utils' + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, + gitExecFileAsyncMock +} = clientMocks + +const ACME = { owner: 'acme', repo: 'widgets' } +const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } +const CONTRIBUTOR = { owner: 'contributor', repo: 'widgets' } + +/** remoteHeads maps a remote to the branch its `refs/remotes//HEAD` points at. */ +function primeGit( + trackedUpstream: string, + remoteHeads: Record = { origin: 'develop' } +): void { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(upstream)')) { + return { stdout: `refs/heads/feature/my-change\0${trackedUpstream}\n`, stderr: '' } + } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + const stdout = Object.entries(remoteHeads) + .filter(([remote]) => args.includes(`refs/remotes/${remote}/HEA[D]`)) + .map( + ([remote, branch]) => `refs/remotes/${remote}/HEAD\0refs/remotes/${remote}/${branch}\n` + ) + .join('') + return { stdout, stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === 'HEAD') { + return { stdout: 'feature-head-oid\n', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) +} + +function restPR( + headRefName = 'develop', + headRepo: OwnerRepo = ACME, + prRepo: OwnerRepo = ACME +): RestPullRequest { + return { + number: 7, + title: 'Release develop', + state: 'closed', + merged_at: null, + html_url: `https://${prRepo.host ?? 'github.com'}/${prRepo.owner}/${prRepo.repo}/pull/7`, + updated_at: '2025-01-01T00:00:00Z', + draft: false, + mergeable: null, + base: { ref: 'release', sha: 'release-oid' }, + head: { + ref: headRefName, + sha: 'old-develop-oid', + repo: { name: headRepo.repo, owner: { login: headRepo.owner } } + } + } +} + +function primeGh( + listsByHead: Record, + exactPR: RestPullRequest | RestPullRequest[] = Object.values(listsByHead).flat()[0] ?? restPR() +): void { + const repositorySlug = (pr: RestPullRequest): string => + new URL(pr.html_url ?? '').pathname.split('/').slice(1, 3).join('/') + const repositoryHost = (pr: RestPullRequest): string => new URL(pr.html_url ?? '').host + const exactPRs = Array.isArray(exactPR) ? exactPR : [exactPR] + const prsByRepositoryAndNumber = new Map( + [...Object.values(listsByHead).flat(), ...exactPRs].map((pr) => [ + `${repositoryHost(pr)}/${repositorySlug(pr)}#${pr.number}`, + pr + ]) + ) + ghExecFileAsyncMock.mockImplementation( + async (args: string[], options: { host?: string } = {}) => { + const host = options.host ?? 'github.com' + const match = args[1]?.match(/^repos\/([^/]+\/[^/]+)\/pulls\?head=([^&]+)/) + if (args[0] === 'api' && match) { + const list = (listsByHead[decodeURIComponent(match[2])] ?? []).filter( + (pr) => repositoryHost(pr) === host && repositorySlug(pr) === match[1] + ) + return { stdout: JSON.stringify(list) } + } + const exactRestMatch = args[1]?.match(/^repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/) + if (args[0] === 'api' && exactRestMatch) { + const pr = prsByRepositoryAndNumber.get(`${host}/${exactRestMatch[1]}#${exactRestMatch[2]}`) + if (pr) { + return { stdout: JSON.stringify(pr) } + } + throw new Error('HTTP 404 Not Found') + } + const repoIndex = args.indexOf('--repo') + const exact = prsByRepositoryAndNumber.get(`${host}/${args[repoIndex + 1]}#${args[2]}`) + if (args[0] === 'pr' && args[1] === 'view') { + if (!exact) { + throw new Error('HTTP 404 Not Found') + } + return { + stdout: JSON.stringify({ + number: exact.number, + title: exact.title, + state: exact.merged_at ? 'MERGED' : exact.state.toUpperCase(), + url: exact.html_url, + statusCheckRollup: [], + updatedAt: exact.updated_at, + isDraft: exact.draft, + mergeable: 'UNKNOWN', + baseRefName: exact.base?.ref, + headRefName: exact.head?.ref, + baseRefOid: exact.base?.sha, + headRefOid: exact.head?.sha, + headRepositoryOwner: exact.head?.repo?.owner, + headRepository: exact.head?.repo ? { name: exact.head.repo.name } : null + }) + } + } + throw new Error(`gh unavailable: ${args.join(' ')}`) + } + ) +} + +/** A fork clone: `origin` is the fork, `upstream` is the repo PRs target. */ +function primeForkWithUpstream(): void { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [UPSTREAM, ACME], + headRepo: ACME + }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'upstream' ? UPSTREAM : remoteName === 'origin' ? ACME : null + ) +} + +describe('issue #26948: a branch tracking the default branch', () => { + beforeEach(() => { + resetPRForBranchMocks(clientMocks) + getOwnerRepoMock.mockResolvedValue(ACME) + }) + + it('does not attach a PR whose head is the default branch', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({ 'acme:develop': [restPR('develop')] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toBeNull() + }) + + it('drops a cached PR number whose head is the tracked default branch', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({}) + + const outcome = await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7) + + expect(outcome.kind).toBe('no-pr') + }) + + it('identifies the rejected merged integration PR so head-current caches can clear it', async () => { + primeGit('refs/remotes/origin/develop') + const integrationPR = { + ...restPR(), + merged_at: '2025-01-02T00:00:00Z', + head: { ...restPR().head, sha: 'feature-head-oid' } + } + primeGh({ 'acme:develop': [integrationPR] }) + + expect( + await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7, { + currentHeadOid: 'feature-head-oid', + acceptMergedFallbackPR: true + }) + ).toMatchObject({ + kind: 'no-pr', + rejectedPRUrls: ['https://github.com/acme/widgets/pull/7'] + }) + }) + + it('skips the default branch on a second remote that PRs target (fork checkout off upstream)', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'develop' }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toBeNull() + }) + + it("uses the tracked remote's own default branch when it differs from origin's", async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toBeNull() + }) + + it("keeps a PR headed by a tracked branch that is only origin's default", async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'main' }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toMatchObject({ number: 7 }) + }) + + it("still follows a contributor fork's branch that shares the default branch's name", async () => { + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'contributor' ? { owner: 'contributor', repo: 'widgets' } : ACME + ) + primeGit('refs/remotes/contributor/develop') + primeGh({ 'contributor:develop': [{ ...restPR('develop', CONTRIBUTOR), state: 'open' }] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toMatchObject({ number: 7, headRepo: { owner: 'contributor', repo: 'widgets' } }) + }) + + it('preserves a cached contributor PR sharing the tracked default branch name', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({}, { ...restPR('develop', CONTRIBUTOR), state: 'open' }) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) + expect(pr).toMatchObject({ number: 7, headRepo: CONTRIBUTOR }) + }) + + it('preserves a PR from the origin fork default branch into upstream', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/origin/develop') + primeGh({ 'acme:develop': [{ ...restPR('develop', ACME, UPSTREAM), state: 'open' }] }) + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + expect(pr).toMatchObject({ number: 7, headRepo: ACME, prRepo: UPSTREAM }) + }) + + it('clears an old cached integration PR after upstream tracking is removed', async () => { + primeGit('') + primeGh({}) + expect( + (await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7)).kind + ).toBe('no-pr') + }) + + it.each(['closed', 'merged'] as const)( + 'clears a cached %s integration PR in a fork after upstream tracking is removed', + async (state) => { + primeForkWithUpstream() + primeGit('', { origin: 'main' }) + const integrationPR = restPR('develop', UPSTREAM, UPSTREAM) + primeGh( + {}, + { + ...integrationPR, + merged_at: state === 'merged' ? '2025-01-02T00:00:00Z' : null, + head: { + ...integrationPR.head, + sha: 'feature-head-oid', + repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'develop' } + } + } + ) + + expect( + await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7, { + currentHeadOid: 'feature-head-oid', + acceptMergedFallbackPR: true + }) + ).toMatchObject({ + kind: 'no-pr', + rejectedPRUrls: ['https://github.com/stablyai/widgets/pull/7'] + }) + } + ) + + it('preserves a cached renamed feature PR using its own repository default metadata', async () => { + primeForkWithUpstream() + primeGit('', { origin: 'develop' }) + const featurePR = restPR('develop', UPSTREAM, UPSTREAM) + primeGh( + {}, + { + ...featurePR, + state: 'open', + head: { + ...featurePR.head, + repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'main' } + } + } + ) + + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ + number: 7, + headRefName: 'develop' + }) + }) + + it.each(['unavailable', 'changed-head'] as const)( + 'preserves an untracked cached PR when REST metadata is %s', + async (metadataState) => { + primeForkWithUpstream() + primeGit('', { origin: 'develop' }) + primeGh({}, { ...restPR('develop', UPSTREAM, UPSTREAM), state: 'open' }) + const ghImplementation = ghExecFileAsyncMock.getMockImplementation() + ghExecFileAsyncMock.mockImplementation(async (args: string[], options) => { + if (args[0] === 'api' && args[1] === 'repos/stablyai/widgets/pulls/7') { + if (metadataState === 'unavailable') { + throw new Error('network unavailable') + } + const pr = restPR('develop', ACME, UPSTREAM) + return { + stdout: JSON.stringify({ + ...pr, + head: { + ...pr.head, + repo: { name: 'widgets', owner: { login: 'acme' }, default_branch: 'develop' } + } + }) + } + } + return ghImplementation?.(args, options) + }) + + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ + number: 7, + headRepo: UPSTREAM + }) + } + ) + + it('tries the cached feature PR after rejecting an unrelated upstream result', async () => { + primeGit('refs/remotes/origin/develop') + primeGh( + { 'acme:develop': [restPR()] }, + { ...restPR('feature/my-change'), number: 8, state: 'open' } + ) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 8) + expect(pr).toMatchObject({ number: 8, headRefName: 'feature/my-change' }) + }) + + it('does not borrow origin default when upstream HEAD is unknown', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'develop' }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ number: 7 }) + }) + + it('uses PR head default metadata when upstream has no recorded HEAD', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main' }) + const pr = restPR('develop', UPSTREAM, UPSTREAM) + primeGh({ + 'stablyai:develop': [ + { + ...pr, + head: { + ...pr.head, + repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'develop' } + } + } + ] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() + }) + + it('preserves explicit links to integration PRs', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({}) + expect(await getPRForBranch('/repo-root', 'feature/my-change', 7)).toMatchObject({ number: 7 }) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('preserves cached review context while detached', async () => { + primeGh({}) + expect(await getPRForBranch('/repo-root', '', null, null, 7)).toMatchObject({ number: 7 }) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not infer a head repository for a cached PR with unavailable fork metadata', async () => { + primeGit('refs/remotes/origin/develop') + const pr = restPR() + primeGh({}, { ...pr, head: { ref: 'develop', sha: 'deleted-fork-oid', repo: null } }) + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ + number: 7 + }) + }) + + it('does not fetch the rejected integration PR twice when its number is cached', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({ 'acme:develop': [restPR()] }) + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toBeNull() + const exactCalls = ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'pr' && args[1] === 'view' + ) + expect(exactCalls).toHaveLength(1) + }) + + it('does not reject a cached PR because another repository rejected the same number', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) + const unrelated = restPR('develop', UPSTREAM, UPSTREAM) + const cached = { ...restPR('feature/my-change'), state: 'open' } + primeGh({ 'stablyai:develop': [unrelated] }, [unrelated, cached]) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) + expect(pr).toMatchObject({ number: 7, prRepo: ACME, headRefName: 'feature/my-change' }) + }) + + it('continues cached number recovery after rejecting another repository default PR', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) + primeGh({}, [ + restPR('develop', UPSTREAM, UPSTREAM), + { ...restPR('feature/my-change'), state: 'open' } + ]) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) + expect(pr).toMatchObject({ number: 7, prRepo: ACME, headRefName: 'feature/my-change' }) + }) + + it('does not accept another repository head with the same owner and branch name', async () => { + const other = { owner: 'acme', repo: 'other-widgets' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [other, ACME], + headRepo: ACME + }) + primeGit('') + primeGh({ + 'acme:feature/my-change': [{ ...restPR('feature/my-change', other, other), state: 'open' }] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() + }) + + it('does not query another GitHub server for a known public GitHub head', async () => { + const enterprise = { ...ACME, host: 'ghe.example' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [enterprise, ACME], + headRepo: ACME + }) + primeGit('') + primeGh({ + 'acme:feature/my-change': [ + { ...restPR('feature/my-change', enterprise, enterprise), state: 'open' } + ] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() + expect( + ghExecFileAsyncMock.mock.calls.some(([, options]) => options?.host === 'ghe.example') + ).toBe(false) + }) + + it('preserves a tracked contributor fork with a different repository name', async () => { + const fork = { owner: 'contributor', repo: 'renamed-widgets' } + getOwnerRepoForRemoteMock.mockImplementation(async (_path: string, remote: string) => + remote === 'contributor' ? fork : ACME + ) + primeGit('refs/remotes/contributor/develop') + primeGh({ 'contributor:develop': [{ ...restPR('develop', fork), state: 'open' }] }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ + number: 7, + headRepo: fork, + prRepo: ACME + }) + }) + + it('continues to a valid branch result after another repository returns the wrong head', async () => { + const other = { owner: 'acme', repo: 'other-widgets' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [other, ACME], + headRepo: ACME + }) + primeGit('') + primeGh({ + 'acme:feature/my-change': [ + { ...restPR('feature/my-change', other, other), state: 'open' }, + { ...restPR('feature/my-change'), state: 'open' } + ] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ + number: 7, + headRepo: ACME, + prRepo: ACME + }) + }) +}) diff --git a/src/main/github/client-tracked-upstream-fork-owner.test.ts b/src/main/github/client-tracked-upstream-fork-owner.test.ts index ee967202c7da..bc38313c2e9d 100644 --- a/src/main/github/client-tracked-upstream-fork-owner.test.ts +++ b/src/main/github/client-tracked-upstream-fork-owner.test.ts @@ -361,7 +361,11 @@ describe('getPRForBranch', () => { await vi.advanceTimersByTimeAsync(30_001) const pr = await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') - expect(sshGitProvider.exec).toHaveBeenCalledTimes(2) + // Why: a found upstream PR also resolves the default branch (#26948); count snapshot probes only. + const snapshotProbes = sshGitProvider.exec.mock.calls.filter(([args]) => + args.includes('refs/heads') + ) + expect(snapshotProbes).toHaveLength(2) expect(pr).toMatchObject({ number: 81, title: 'Fresh SSH upstream PR' diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index 1eb2200415d2..894c4481a097 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -113,7 +113,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client/lookup/branch-lookup-resolution.ts b/src/main/github/client/lookup/branch-lookup-resolution.ts index 2efe7d9cd256..4c30a4dad717 100644 --- a/src/main/github/client/lookup/branch-lookup-resolution.ts +++ b/src/main/github/client/lookup/branch-lookup-resolution.ts @@ -17,6 +17,8 @@ import { isMergedImplicitPR, shouldHideMergedImplicitPR, getCurrentHeadOid, + pullRequestHeadRepository, + linkedMergedPRDivergedHeadOid, type PullRequestLookupData, type GitHubPRBranchLookupOptions } from './pull-request-lookup-data' @@ -26,6 +28,10 @@ import { derivePRRefreshData } from './branch-lookup-derived-data' import { assemblePRRefreshFoundOutcome } from './pr-refresh-outcome-assembly' import { shouldRetryTrackedUpstreamBranch } from './tracked-upstream-cache' import { getTrackedUpstreamBranch } from './tracked-upstream-branch' +import { + createImplicitDefaultBranchPRPolicy, + type ImplicitDefaultBranchPRContext +} from './implicit-default-branch-pr' import { PR_BRANCH_LOOKUP_BUCKETS } from './pr-lookup-rate-limit' import type { HostedReviewLocalGitOptions } from './../github-exec-scope' export async function resolvePRForBranchOutcome(input: { @@ -74,13 +80,23 @@ export async function resolvePRForBranchOutcome(input: { let hasPendingBranchLookupError = false let currentHeadOidForMergedImplicit: string | null | undefined let usedExactNumberLookup = false + const defaultBranchPRContext: ImplicitDefaultBranchPRContext = { + originHeadRepo: headRepo, + trackedUpstream: null, + branchName, + linkedPRNumber, + repoPath, + connectionId, + localGitOptions, + ghOptions + } + const implicitPRPolicy = createImplicitDefaultBranchPRPolicy(defaultBranchPRContext) const explicitCurrentHeadOid = typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 ? options.currentHeadOid.trim() : null let confirmedContainedHeadOid: string | null = null - let headDivergedFromMergedPRAtOid: string | null = null const mergedPRContainsHead = async ( candidate: PullRequestLookupData, candidateRepo: OwnerRepo | null, @@ -100,29 +116,6 @@ export async function resolvePRForBranchOutcome(input: { } return membership } - const recordLinkedMergedPRDivergence = async ( - candidate: PullRequestLookupData | null, - candidateRepo: OwnerRepo | null - ): Promise => { - if ( - typeof linkedPRNumber !== 'number' || - !candidate || - mapPRState(candidate.state, candidate.isDraft) !== 'merged' || - explicitCurrentHeadOid === null || - candidate.headRefOid === explicitCurrentHeadOid - ) { - return - } - const membership = await mergedPRContainsHead( - candidate, - candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url), - explicitCurrentHeadOid - ) - if (membership === 'not-contained') { - // explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree. - headDivergedFromMergedPRAtOid = explicitCurrentHeadOid - } - } const hideMergedImplicitPR = async ( candidate: PullRequestLookupData | null, candidateRepo: OwnerRepo | null @@ -162,7 +155,8 @@ export async function resolvePRForBranchOutcome(input: { headRepo, branchName, ghOptions, - executionScope + executionScope, + policy: implicitPRPolicy }) data = branchLookup.data dataRepo = branchLookup.dataRepo @@ -190,12 +184,17 @@ export async function resolvePRForBranchOutcome(input: { upstreamHeadRepo && shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo) ) { + defaultBranchPRContext.trackedUpstream = { + branch: upstreamBranch, + headRepo: upstreamHeadRepo + } const upstreamLookup = await lookupPRByBranchName({ candidates, headRepo: upstreamHeadRepo, branchName: upstreamBranch.branchName, ghOptions, - executionScope + executionScope, + policy: implicitPRPolicy }) data = upstreamLookup.data dataRepo = upstreamLookup.dataRepo @@ -223,7 +222,8 @@ export async function resolvePRForBranchOutcome(input: { candidates, number: fallbackPRNumber, ghOptions, - executionScope + executionScope, + policy: implicitPRPolicy }) data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo @@ -232,9 +232,26 @@ export async function resolvePRForBranchOutcome(input: { if (hasPendingBranchLookupError) { return prRefreshUpstreamError(pendingBranchLookupError) } - return { kind: 'no-pr', fetchedAt: Date.now() } + return { + kind: 'no-pr', + fetchedAt: Date.now(), + ...(implicitPRPolicy.rejectedPRUrls.size > 0 + ? { rejectedPRUrls: [...implicitPRPolicy.rejectedPRUrls] } + : {}) + } } - await recordLinkedMergedPRDivergence(data, dataRepo) + dataHeadRepo = pullRequestHeadRepository(data, dataRepo) ?? dataHeadRepo + const headDivergedFromMergedPRAtOid = await linkedMergedPRDivergedHeadOid( + data, + linkedPRNumber, + explicitCurrentHeadOid, + (candidate, headOid) => + mergedPRContainsHead( + candidate, + dataRepo ?? ownerRepoFromPullRequestUrl(candidate.url), + headOid + ) + ) const fallbackConfirmedMergedBranch = typeof fallbackPRNumber === 'number' && mergedBranchLookupNumber === fallbackPRNumber && diff --git a/src/main/github/client/lookup/implicit-default-branch-pr.ts b/src/main/github/client/lookup/implicit-default-branch-pr.ts new file mode 100644 index 000000000000..ca852798beab --- /dev/null +++ b/src/main/github/client/lookup/implicit-default-branch-pr.ts @@ -0,0 +1,103 @@ +import type { OwnerRepo } from '../../gh-utils' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { + getRemoteHeadBranchName, + getRepoDefaultBranchName +} from '../../../source-control/repo-default-branch' +import type { GhExecOptions, HostedReviewLocalGitOptions } from './../github-exec-scope' +import type { TrackedUpstreamBranch } from './tracked-upstream-cache' +import { + pullRequestHeadRepository, + type PullRequestLookupData, + type PullRequestLookupPolicy +} from './pull-request-lookup-data' +import { getRestPRByNumber } from './pr-number-lookup' + +export type ImplicitDefaultBranchPRContext = { + originHeadRepo: OwnerRepo | null + trackedUpstream: { branch: TrackedUpstreamBranch; headRepo: OwnerRepo } | null + branchName: string + linkedPRNumber?: number | null + repoPath: string + connectionId?: string | null + localGitOptions: HostedReviewLocalGitOptions + ghOptions: GhExecOptions +} + +export function createImplicitDefaultBranchPRPolicy( + context: ImplicitDefaultBranchPRContext +): PullRequestLookupPolicy { + const rejected = new Set() + const rejectedPRUrls = new Set() + return { + rejectedPRUrls, + async accepts(data, prRepo) { + const hidden = await shouldHideImplicitDefaultBranchPR({ ...context, data, prRepo }) + if (hidden && prRepo) { + rejected.add(`${githubRepoIdentityKey(prRepo)}#${data.number}`) + if (data.url) { + rejectedPRUrls.add(data.url) + } + } + return !hidden + }, + isRejected(prRepo, number) { + return rejected.has(`${githubRepoIdentityKey(prRepo)}#${number}`) + } + } +} + +async function shouldHideImplicitDefaultBranchPR( + input: ImplicitDefaultBranchPRContext & { data: PullRequestLookupData; prRepo: OwnerRepo | null } +): Promise { + const { data, branchName, prRepo } = input + if ( + !branchName || + !data.headRefName || + data.headRefName === branchName || + typeof input.linkedPRNumber === 'number' + ) { + return false + } + const headRepo = pullRequestHeadRepository(data, prRepo) + // Fork heads are real review branches even when their name is a default branch elsewhere. + if (!headRepo || !prRepo || githubRepoIdentityKey(headRepo) !== githubRepoIdentityKey(prRepo)) { + return false + } + if (data.headDefaultBranchName) { + return data.headRefName === data.headDefaultBranchName + } + const headRepoKey = githubRepoIdentityKey(headRepo) + const remoteName = + input.trackedUpstream && githubRepoIdentityKey(input.trackedUpstream.headRepo) === headRepoKey + ? input.trackedUpstream.branch.remoteName + : input.originHeadRepo && githubRepoIdentityKey(input.originHeadRepo) === headRepoKey + ? 'origin' + : null + if (!remoteName) { + try { + // Removed tracking can leave a cached integration PR without a matching remote. + const metadata = await getRestPRByNumber(prRepo, data.number, input.ghOptions) + const metadataHeadRepo = pullRequestHeadRepository(metadata, prRepo) + return ( + metadataHeadRepo !== null && + githubRepoIdentityKey(metadataHeadRepo) === headRepoKey && + metadata.headRefName === data.headRefName && + metadata.headDefaultBranchName === data.headRefName + ) + } catch { + return false + } + } + // An unknown upstream HEAD cannot borrow a different repository's origin default. + const defaultBranchName = + remoteName === 'origin' + ? await getRepoDefaultBranchName(input.repoPath, input.connectionId, input.localGitOptions) + : await getRemoteHeadBranchName( + input.repoPath, + remoteName, + input.connectionId, + input.localGitOptions + ) + return defaultBranchName !== null && data.headRefName === defaultBranchName +} diff --git a/src/main/github/client/lookup/pr-branch-lookup.ts b/src/main/github/client/lookup/pr-branch-lookup.ts index 2ee667ceacc4..82fc4494a7c3 100644 --- a/src/main/github/client/lookup/pr-branch-lookup.ts +++ b/src/main/github/client/lookup/pr-branch-lookup.ts @@ -1,6 +1,10 @@ import { ghExecFileAsync } from '../../gh-utils' import type { OwnerRepo, ghRepoExecOptions } from '../../gh-utils' -import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +import { + githubHostExecOptions, + githubRepositoryWebHost, + type GitHubApiRepository +} from '../../github-api-repository' import type { GhExecOptions } from './../github-exec-scope' import { isNoPullRequestError } from './../gh-error-predicates' import { @@ -8,7 +12,9 @@ import { PR_BRANCH_LIST_JSON_FIELDS, mapRestPullRequest, normalizePullRequestLookupData, + pullRequestMatchesHeadRepository, type PullRequestLookupData, + type PullRequestLookupPolicy, type RestPullRequest } from './pull-request-lookup-data' import { getPRByNumber } from './pr-number-lookup' @@ -79,6 +85,7 @@ export async function lookupPRByBranchName(args: { branchName: string ghOptions: GhExecOptions executionScope: string + policy?: PullRequestLookupPolicy }): Promise<{ data: PullRequestLookupData | null dataRepo: OwnerRepo | null @@ -88,6 +95,13 @@ export async function lookupPRByBranchName(args: { let pendingError: unknown let hasPendingError = false for (const candidate of args.candidates) { + if ( + args.headRepo && + githubRepositoryWebHost(candidate).trim().toLowerCase() !== + githubRepositoryWebHost(args.headRepo).trim().toLowerCase() + ) { + continue + } try { const branchData = args.headRepo ? await getRestPRForBranch( @@ -104,7 +118,11 @@ export async function lookupPRByBranchName(args: { args.ghOptions, args.executionScope ) - if (data) { + if ( + data && + pullRequestMatchesHeadRepository(data, candidate, args.headRepo) && + (!args.policy || (await args.policy.accepts(data, candidate))) + ) { return { data, dataRepo: candidate } } } catch (err) { @@ -128,7 +146,11 @@ export async function lookupPRByBranchName(args: { args.ghOptions, args.executionScope ) - if (data) { + if ( + data && + pullRequestMatchesHeadRepository(data, candidate, args.headRepo) && + (!args.policy || (await args.policy.accepts(data, candidate))) + ) { return { data, dataRepo: candidate } } } catch (retryErr) { @@ -150,10 +172,13 @@ export async function lookupPRByBranchName(args: { ['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS], args.ghOptions ) - return { + const lookup = { data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), dataRepo: null } + return !args.policy || (await args.policy.accepts(lookup.data, null)) + ? lookup + : { data: null, dataRepo: null } } catch (err) { if (isNoPullRequestError(err)) { return { data: null, dataRepo: null } diff --git a/src/main/github/client/lookup/pr-number-lookup.ts b/src/main/github/client/lookup/pr-number-lookup.ts index 42a605eb295c..3df2bdeaf5bd 100644 --- a/src/main/github/client/lookup/pr-number-lookup.ts +++ b/src/main/github/client/lookup/pr-number-lookup.ts @@ -11,6 +11,7 @@ import { mapRestPullRequest, normalizePullRequestLookupData, type PullRequestLookupData, + type PullRequestLookupPolicy, type RestPullRequest } from './pull-request-lookup-data' import { hydratePullRequestLookupData } from './pull-request-lookup-hydration' @@ -108,8 +109,12 @@ export async function lookupPRByNumber(args: { number: number ghOptions: ReturnType executionScope: string + policy?: PullRequestLookupPolicy }): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { for (const candidate of args.candidates) { + if (args.policy?.isRejected(candidate, args.number)) { + continue + } try { const linkedData = await getPRByNumber( candidate, @@ -117,7 +122,7 @@ export async function lookupPRByNumber(args: { args.ghOptions, args.executionScope ) - if (!linkedData) { + if (!linkedData || (args.policy && !(await args.policy.accepts(linkedData, candidate)))) { continue } return { data: linkedData, dataRepo: candidate } @@ -138,10 +143,13 @@ export async function lookupPRByNumber(args: { ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS], args.ghOptions ) - return { + const lookup = { data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), dataRepo: null } + return !args.policy || (await args.policy.accepts(lookup.data, null)) + ? lookup + : { data: null, dataRepo: null } } catch (err) { if (isNoPullRequestError(err)) { // Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo. diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts index 03c431b2e8db..8d4a00cea43c 100644 --- a/src/main/github/client/lookup/pull-request-lookup-data.ts +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -4,10 +4,12 @@ import type { PRMergeableState, PRReviewDecision } from '../../../../shared/github/pull-request-types' -import { gitExecFileAsync } from '../../gh-utils' +import { gitExecFileAsync, type OwnerRepo } from '../../gh-utils' import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' import type { HostedReviewExecutionOptions } from '../../../source-control/hosted-review-git-options' import { mapPRState } from '../../mappers' +import type { MergedPRCommitMembership } from '../../merged-pr-commit-membership' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { normalizePRMergeable, normalizeReviewDecision, @@ -35,10 +37,19 @@ export type PullRequestLookupData = { headRefName?: string baseRefOid?: string headRefOid?: string + headRepositoryOwner?: { login?: string } | null + headRepository?: { name?: string } | null + headDefaultBranchName?: string stack?: GitHubPRStack stackMetadataChecked?: boolean } +export type PullRequestLookupPolicy = { + accepts: (data: PullRequestLookupData, prRepo: OwnerRepo | null) => Promise + isRejected: (prRepo: OwnerRepo, number: number) => boolean + rejectedPRUrls: ReadonlySet +} + export type RestPullRequest = { number: number title: string @@ -51,7 +62,12 @@ export type RestPullRequest = { mergeable?: boolean | null mergeable_state?: string | null base?: { ref?: string; sha?: string } - head?: { ref?: string; sha?: string } + head?: { + ref?: string + sha?: string + repo?: { name?: string; owner?: { login?: string }; default_branch?: string } | null + user?: { login?: string } + } stack?: { number?: number position?: number @@ -61,10 +77,10 @@ export type RestPullRequest = { } export const PR_LOOKUP_JSON_FIELDS = - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' export const PR_BRANCH_LIST_JSON_FIELDS = - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & { acceptMergedFallbackPR?: boolean @@ -118,11 +134,36 @@ export function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { headRefName: pr.head?.ref, baseRefOid: pr.base?.sha, headRefOid: pr.head?.sha, + headRepositoryOwner: pr.head?.repo?.owner ?? pr.head?.user, + headRepository: pr.head?.repo, + headDefaultBranchName: pr.head?.repo?.default_branch, stackMetadataChecked: true, ...(stack ? { stack } : {}) } } +export function pullRequestHeadRepository( + data: PullRequestLookupData, + prRepo: OwnerRepo | null +): OwnerRepo | null { + const owner = data.headRepositoryOwner?.login?.trim() + const repo = data.headRepository?.name?.trim() + return owner && repo ? { owner, repo, ...(prRepo?.host ? { host: prRepo.host } : {}) } : null +} + +export function pullRequestMatchesHeadRepository( + data: PullRequestLookupData, + prRepo: OwnerRepo | null, + expectedHeadRepo: OwnerRepo | null +): boolean { + const actualHeadRepo = pullRequestHeadRepository(data, prRepo) + return ( + !actualHeadRepo || + !expectedHeadRepo || + githubRepoIdentityKey(actualHeadRepo) === githubRepoIdentityKey(expectedHeadRepo) + ) +} + export function isMergedImplicitPR( data: PullRequestLookupData, linkedPRNumber?: number | null @@ -143,6 +184,24 @@ export function shouldHideMergedImplicitPR( return !currentHeadOid || data.headRefOid !== currentHeadOid } +export async function linkedMergedPRDivergedHeadOid( + data: PullRequestLookupData | null, + linkedPRNumber: number | null | undefined, + currentHeadOid: string | null, + containsHead: (data: PullRequestLookupData, headOid: string) => Promise +): Promise { + if ( + typeof linkedPRNumber !== 'number' || + !data || + mapPRState(data.state, data.isDraft) !== 'merged' || + currentHeadOid === null || + data.headRefOid === currentHeadOid + ) { + return null + } + return (await containsHead(data, currentHeadOid)) === 'not-contained' ? currentHeadOid : null +} + export function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData { return { ...data, diff --git a/src/main/hermes/hermes-config-comment-boundaries.test.ts b/src/main/hermes/hermes-config-comment-boundaries.test.ts index b28ad582ed91..e1bf9ab6f16e 100644 --- a/src/main/hermes/hermes-config-comment-boundaries.test.ts +++ b/src/main/hermes/hermes-config-comment-boundaries.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { parseDocument } from 'yaml' -import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml' import { HermesHookService } from './hook-service' diff --git a/src/main/hermes/hermes-config-remote-adapter.test.ts b/src/main/hermes/hermes-config-remote-adapter.test.ts index 3005d9ef9dd4..2c860b85798c 100644 --- a/src/main/hermes/hermes-config-remote-adapter.test.ts +++ b/src/main/hermes/hermes-config-remote-adapter.test.ts @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' import { HermesHookService } from './hook-service' describe('Hermes remote installer through a local filesystem adapter (no SSH)', () => { diff --git a/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts b/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts index 8f998d440381..7724f33305d5 100644 --- a/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts +++ b/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts @@ -11,7 +11,10 @@ const mocks = vi.hoisted(() => ({ local: vi.fn<() => Promise>(), ssh: vi.fn<(host: string, args?: AiVaultListArgs) => Promise>() })) -vi.mock('electron', () => ({ app: { on: vi.fn() }, ipcMain: { handle: vi.fn() } })) +vi.mock('electron', () => ({ + app: { on: vi.fn() }, + ipcMain: { handle: vi.fn() } +})) vi.mock('../ai-vault/cached-session-list', () => ({ configureAiVaultSessionSources: vi.fn(), resetAiVaultSessionListCacheForTests: vi.fn(), @@ -19,7 +22,9 @@ vi.mock('../ai-vault/cached-session-list', () => ({ getAiVaultWslHomeDirs: vi.fn().mockResolvedValue([]), listAiVaultSessions: mocks.local })) -vi.mock('../ai-vault/ssh-session-list', () => ({ scanSshAiVaultSessions: mocks.ssh })) +vi.mock('../ai-vault/ssh-session-list', () => ({ + scanSshAiVaultSessions: mocks.ssh +})) vi.mock('./ssh', () => ({ getActiveSshAiVaultHostInfos: () => ['one', 'two', 'three', 'four'].map((targetId) => ({ targetId })) @@ -56,7 +61,13 @@ function result(marker: string): AiVaultListResult { } function args(scan: () => Promise, force = false, depth = 4) { - return { cacheKey: 'leg', depth, force, scopePaths: ['/workspaces/project'], scan } + return { + cacheKey: 'leg', + depth, + force, + scopePaths: ['/workspaces/project'], + scan + } } async function cacheOwner( @@ -89,7 +100,11 @@ async function cacheOwner( beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(1000) - mocks.local.mockResolvedValue({ sessions: [], issues: [], scannedAt: '2026-10-02T00:00:00Z' }) + mocks.local.mockResolvedValue({ + sessions: [], + issues: [], + scannedAt: '2026-10-02T00:00:00Z' + }) mocks.ssh.mockImplementation(async (host, input) => result(`${host}-${input?.scopePaths?.[0]}`)) }) afterEach(() => { @@ -100,27 +115,74 @@ afterEach(() => { }) describe('host session result cache lifetime', () => { - it('releases both host legs and merged lists at the strict idle TTL with one unref timer', async () => { - const timeouts = vi.spyOn(globalThis, 'setTimeout') - const owner = await cacheOwner(async () => { - for (let scope = 0; scope < 8; scope++) { - await _internals.listAiVaultSessions({ - executionHostScope: 'all', - scopePaths: [`/workspaces/project-${scope}`] - }) - } - }, '|ssh:one') - expect(owner.size).toBe(40) - const pending = vi.getTimerCount() - const timer = timeouts.mock.results[0]?.value - await vi.advanceTimersByTimeAsync(AI_VAULT_CACHE_TTL_MS - 1) - expect(owner.size).toBe(40) - await vi.advanceTimersByTimeAsync(1) + it('uses compact proofs only for an exact query depth and fingerprint until the same idle TTL', async () => { + const fingerprint = 'a'.repeat(64) + const compact: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: 'fresh', + fingerprint, + unchanged: true + } + const scan = vi.fn().mockResolvedValue(compact) + const request = { ...args(scan), afterFingerprint: fingerprint } + const owner = await cacheOwner(() => scanHostLegWithCache(request)) + expect(await scanHostLegWithCache(request)).toEqual(compact) + expect(scan).toHaveBeenCalledTimes(1) + expect(owner.size).toBe(1) + await vi.advanceTimersByTimeAsync(AI_VAULT_CACHE_TTL_MS) expect(owner.size).toBe(0) - expect(pending).toBe(1) - expect(timer?.hasRef()).toBe(false) - expect(vi.getTimerCount()).toBe(0) - expect(mocks.ssh).toHaveBeenCalledTimes(32) + await scanHostLegWithCache(request) + expect(scan).toHaveBeenCalledTimes(2) + for (const afterFingerprint of [undefined, null, 'b'.repeat(64)]) { + scan.mockResolvedValue(result('full')) + const calls = scan.mock.calls.length + await scanHostLegWithCache({ ...request, afterFingerprint }) + expect(scan).toHaveBeenCalledTimes(calls + 1) + invalidateAiVaultHostLegCache() + scan.mockResolvedValue(compact) + await scanHostLegWithCache(request) + scan.mockClear() + } + scan.mockResolvedValue(result('shallow')) + await scanHostLegWithCache({ ...request, depth: 2 }) + expect(scan).toHaveBeenCalledTimes(1) + }) + + it('restores the last full list behind an unchanged reply for fingerprint-less readers', async () => { + const fingerprint = 'a'.repeat(64) + const full = { ...result('full'), fingerprint } + const scan = vi.fn().mockResolvedValue(full) + await scanHostLegWithCache({ ...args(scan), afterFingerprint: null }) + await vi.advanceTimersByTimeAsync(AI_VAULT_CACHE_TTL_MS) + const compact = { + sessions: [], + issues: [], + scannedAt: 'fresh', + fingerprint, + unchanged: true + } + scan.mockResolvedValue(compact) + expect( + await scanHostLegWithCache({ + ...args(scan), + afterFingerprint: fingerprint + }) + ).toEqual(compact) + const lookup = await scanHostLegWithCache(args(scan)) + expect(scan).toHaveBeenCalledTimes(2) + expect(lookup.unchanged).toBeUndefined() + expect(lookup.scannedAt).toBe('fresh') + expect(lookup.sessions.map((session) => session.id)).toEqual( + [0, 1, 2, 3].map((index) => `local:codex:full-${index}`) + ) + invalidateAiVaultHostLegCache() + await scanHostLegWithCache({ + ...args(scan), + afterFingerprint: fingerprint + }) + await scanHostLegWithCache(args(scan)) + expect(scan).toHaveBeenCalledTimes(4) }) it('keeps the refreshed deadline and cached scoped depth when the old timer fires', async () => { diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts index 889ac4aa18b6..08ed9c81ed26 100644 --- a/src/main/ipc/ai-vault-host-leg-cache.ts +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -24,6 +24,14 @@ const cachedHostLegs = new Map() // otherwise a delete's invalidation is silently undone by a scan that resolves // just after it. let cacheGeneration = 0 +// Why: an `unchanged` reply carries no rows, so without the last full list the +// TTL cache could only hold the empty marker and fingerprint-less readers (tab +// session lookup, other windows) would pay a full remote scan. +const lastFullHostLegs = new Map< + string, + { depth: AiVaultSessionDepth; result: AiVaultListResult } +>() +const LAST_FULL_HOST_LEGS_MAX = 8 let expiryTimer: ReturnType | null = null function scheduleHostLegExpiry(): void { @@ -53,6 +61,7 @@ export async function scanHostLegWithCache(args: { depth: AiVaultSessionDepth scopePaths?: readonly string[] force: boolean + afterFingerprint?: string | null scan: () => Promise }): Promise { const now = Date.now() @@ -62,7 +71,9 @@ export async function scanHostLegWithCache(args: { !args.force && cached && cached.expiresAt > now && - aiVaultSessionDepthCovers(cached.depth, args.depth) + aiVaultSessionDepthCovers(cached.depth, args.depth) && + (!cached.result.unchanged || + (cached.depth === args.depth && cached.result.fingerprint === args.afterFingerprint)) ) { return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths) } @@ -85,19 +96,47 @@ export async function scanHostLegWithCache(args: { !args.force && current && current.expiresAt > Date.now() && - aiVaultSessionDepthCovers(current.depth, args.depth) + aiVaultSessionDepthCovers(current.depth, args.depth) && + (!current.result.unchanged || + (current.depth === args.depth && current.result.fingerprint === args.afterFingerprint)) ) { return result } cachedHostLegs.set(args.cacheKey, { depth: args.depth, - result, + result: rememberFullHostLeg(args.cacheKey, args.depth, result), expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS }) scheduleHostLegExpiry() return result } +/** Returns the full list an `unchanged` reply stands for when one is known. */ +function rememberFullHostLeg( + cacheKey: string, + depth: AiVaultSessionDepth, + result: AiVaultListResult +): AiVaultListResult { + if (!result.fingerprint) { + return result + } + const previous = lastFullHostLegs.get(cacheKey) + if (result.unchanged) { + return previous?.depth === depth && previous.result.fingerprint === result.fingerprint + ? { ...previous.result, scannedAt: result.scannedAt } + : result + } + lastFullHostLegs.delete(cacheKey) + lastFullHostLegs.set(cacheKey, { depth, result }) + for (const oldest of lastFullHostLegs.keys()) { + if (lastFullHostLegs.size <= LAST_FULL_HOST_LEGS_MAX) { + break + } + lastFullHostLegs.delete(oldest) + } + return result +} + function pruneExpiredHostLegs(now: number): void { for (const [cacheKey, entry] of cachedHostLegs) { if (entry.expiresAt <= now) { @@ -113,6 +152,7 @@ function pruneExpiredHostLegs(now: number): void { export function invalidateAiVaultHostLegCache(): void { cacheGeneration++ cachedHostLegs.clear() + lastFullHostLegs.clear() if (expiryTimer !== null) { clearTimeout(expiryTimer) } diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts index 0f7cb7a4109f..7485415c555b 100644 --- a/src/main/ipc/ai-vault-runtime-scan.ts +++ b/src/main/ipc/ai-vault-runtime-scan.ts @@ -68,6 +68,9 @@ function runtimeScanArgs( listArgs: AiVaultListArgs | undefined ): AiVaultListArgs { const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } + if (listArgs?.afterFingerprint !== undefined) { + scanArgs.afterFingerprint = listArgs.afterFingerprint + } if (listArgs?.limit !== undefined) { scanArgs.limit = listArgs.limit } diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 157161f78523..9fbab9b0c0c7 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -66,6 +66,39 @@ beforeEach(() => { }) describe('Agent Session History scan coalescing', () => { + it('never shares a compact reply with a caller missing its exact baseline', async () => { + registerRuntimeHost() + const fingerprint = 'a'.repeat(64) + mocks.scanRuntimeAiVaultSessions.mockImplementation(async (_host, args) => { + await Promise.resolve() + return args.afterFingerprint === fingerprint + ? { ...EMPTY_RESULT, fingerprint, unchanged: true } + : { ...EMPTY_RESULT, scannedAt: 'full' } + }) + const [conditional, legacy] = await Promise.all([ + _internals.listAiVaultSessions({ + executionHostScope: 'runtime:remote-server', + afterFingerprint: fingerprint + }), + _internals.listAiVaultSessions({ executionHostScope: 'runtime:remote-server' }) + ]) + expect(conditional.unchanged).toBe(true) + expect(legacy.unchanged).toBeUndefined() + expect(legacy.scannedAt).toBe('full') + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(2) + }) + + it('does not forward conditional hints into truncated all-host merges', async () => { + registerRuntimeHost() + await _internals.listAiVaultSessions({ + executionHostScope: 'all', + afterFingerprint: 'a'.repeat(64) + }) + expect(mocks.scanRuntimeAiVaultSessions.mock.calls[0]?.[1]).not.toHaveProperty( + 'afterFingerprint' + ) + }) + it.each([ ['local', mocks.scanAiVaultSessionsInService], ['runtime:remote-server', mocks.scanRuntimeAiVaultSessions] @@ -94,92 +127,6 @@ describe('Agent Session History scan coalescing', () => { await expect(second).resolves.toEqual(EMPTY_RESULT) }) - it('coalesces every all-host leg while isolating caller cancellation', async () => { - let resolveRuntime: ((result: AiVaultListResult) => void) | undefined - mocks.scanRuntimeAiVaultSessions.mockImplementation( - () => - new Promise((resolve) => { - resolveRuntime = resolve - }) - ) - registerRuntimeHost() - const controller = new AbortController() - - const first = _internals.listAiVaultSessions( - { executionHostScope: 'all' }, - { signal: controller.signal } - ) - const firstRejection = expect(first).rejects.toMatchObject({ name: 'AbortError' }) - const second = _internals.listAiVaultSessions({ executionHostScope: 'all' }) - await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) - - expect(mocks.scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1) - controller.abort() - await firstRejection - resolveRuntime?.(EMPTY_RESULT) - await expect(second).resolves.toMatchObject({ sessions: [], issues: [] }) - }) - - it.each(['cancel', 'destroyed', 'render-process-gone', 'did-navigate'])( - 'keeps a shared multi-window scan alive when one window emits %s', - async (eventName) => { - let resolveRelay: ((result: AiVaultListResult) => void) | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - () => - new Promise((resolve) => { - resolveRelay = resolve - }) - ) - registerAiVaultHandlers() - const list = ipcHandler('aiVault:listSessions') - const cancel = ipcHandler('aiVault:cancelListSessions') - const firstEvent = { sender: Object.assign(new EventEmitter(), { id: 1 }) } - const secondEvent = { sender: Object.assign(new EventEmitter(), { id: 2 }) } - const first = list(firstEvent, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan' - }) as Promise - const second = list(secondEvent, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan' - }) as Promise - await vi.waitFor(() => expect(resolveRelay).toBeDefined()) - - if (eventName === 'cancel') { - cancel(firstEvent, { requestToken: 'scan' }) - } else { - firstEvent.sender.emit(eventName) - } - - // Electron logs every rejected handler, so a cancelled scan resolves instead. - await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] }) - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) - resolveRelay?.(EMPTY_RESULT) - await expect(second).resolves.toEqual(EMPTY_RESULT) - } - ) - - it('reports a real scan failure as a host issue rather than cancellation', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay socket closed')) - mocks.scanRemoteAiVaultSessions.mockRejectedValue(new Error('relay socket closed')) - registerAiVaultHandlers() - const list = ipcHandler('aiVault:listSessions') - - // SSH host legs convert unexpected throws into scan issues so an `all` - // multi-host list still returns the other hosts' sessions. - const result = await list( - { sender: Object.assign(new EventEmitter(), { id: 1 }) }, - { executionHostScope: 'ssh:dev-box', requestToken: 'scan' } - ) - expect(result).toMatchObject({ - sessions: [], - issues: [expect.objectContaining({ message: 'relay socket closed', kind: 'host' })] - }) - expect(result).not.toHaveProperty('cancelled') - }) - it('reports a failed local scan as a host issue rather than rejecting', async () => { mocks.scanAiVaultSessionsInService.mockRejectedValue(new Error('transcript root is unreadable')) registerAiVaultHandlers() @@ -197,98 +144,6 @@ describe('Agent Session History scan coalescing', () => { }) expect(result).not.toHaveProperty('cancelled') }) - - it('aborts local and SSH all-host legs only after the last renderer leaves', async () => { - const scanSignals: AbortSignal[] = [] - const waitForAbort = (signal: AbortSignal): Promise => { - scanSignals.push(signal) - return new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) - }) - } - mocks.scanAiVaultSessionsInService.mockImplementation((_args, signal: AbortSignal) => - waitForAbort(signal) - ) - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId, _params, options: { signal: AbortSignal }) => waitForAbort(options.signal) - ) - let resolveRuntime: ((result: AiVaultListResult) => void) | undefined - mocks.scanRuntimeAiVaultSessions.mockImplementation( - () => - new Promise((resolve) => { - resolveRuntime = resolve - }) - ) - registerRuntimeHost() - const list = ipcHandler('aiVault:listSessions') - const firstEvent = { sender: Object.assign(new EventEmitter(), { id: 1 }) } - const secondEvent = { sender: Object.assign(new EventEmitter(), { id: 2 }) } - const first = list(firstEvent, { executionHostScope: 'all', requestToken: 'scan' }) - const second = list(secondEvent, { executionHostScope: 'all', requestToken: 'scan' }) - await vi.waitFor(() => expect(scanSignals).toHaveLength(2)) - await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) - - firstEvent.sender.emit('did-navigate') - await expect(first).resolves.toMatchObject({ cancelled: true }) - expect(scanSignals.every((signal) => !signal.aborted)).toBe(true) - secondEvent.sender.emit('render-process-gone') - await expect(second).resolves.toMatchObject({ cancelled: true }) - expect(scanSignals.every((signal) => signal.aborted)).toBe(true) - for (const event of [firstEvent, secondEvent]) { - expect(event.sender.eventNames()).toEqual([]) - } - // Runtime RPC cannot be canceled; its late result remains safely observed. - resolveRuntime?.(EMPTY_RESULT) - }) - - it('does not start an abandoned scan after ownership initialization completes', async () => { - let finishOwnership: (() => void) | undefined - registerAiVaultHandlers({ - ensureStructuredSessionOwnership: () => - new Promise((resolve) => { - finishOwnership = resolve - }) - }) - const event = { sender: Object.assign(new EventEmitter(), { id: 1 }) } - const pending = ipcHandler('aiVault:listSessions')(event, { requestToken: 'scan' }) - - event.sender.emit('did-navigate') - finishOwnership?.() - - await expect(pending).resolves.toMatchObject({ cancelled: true }) - expect(mocks.scanAiVaultSessionsInService).not.toHaveBeenCalled() - expect(event.sender.eventNames()).toEqual([]) - }) - - it('re-joins a preempted same-scope caller onto the forced refresh', async () => { - const signals: AbortSignal[] = [] - let resolveForced: ((result: AiVaultListResult) => void) | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId, _params, options: { signal: AbortSignal }) => { - signals.push(options.signal) - return new Promise((resolve) => { - if (signals.length === 1) { - options.signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) - } else { - resolveForced = resolve - } - }) - } - ) - const first = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - await vi.waitFor(() => expect(signals).toHaveLength(1)) - - const forced = _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - force: true - }) - await vi.waitFor(() => expect(signals).toHaveLength(2)) - - expect(signals[0]?.aborted).toBe(true) - resolveForced?.(EMPTY_RESULT) - // Another window's Refresh must not surface as this caller's cancellation. - await expect(Promise.all([first, forced])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) - }) }) function registerRuntimeHost(): void { diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index 21c8b3ff6960..b3907603952a 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -85,29 +85,6 @@ describe('desktop IPC and preload search boundary', () => { await expect(handlers.get('aiVault:searchSessions')!(null, { query: 1 })).rejects.toThrow() await expect(handlers.get('aiVault:searchStatus')!(null, 42)).rejects.toThrow() }) - it('routes one SSH target without touching the local index and redacts received paths', async () => { - const local = fakeSearchService() - setSessionSearchService(local) - sshSearch.mockResolvedValue(searchResults()) - const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host') - expect(sshSearch).toHaveBeenCalledWith('ssh-host', 'aiVault.searchSessions', { - query: 'needle', - limit: 20, - supportedAgents: [...AI_VAULT_AGENTS], - supportsQoderHistory: true, - supportsJcodeHistory: true - }) - expect(result).toMatchObject({ - hits: [{ executionHostId: 'ssh:ssh-host', source: { presence: 'present' } }] - }) - expect(JSON.stringify(result)).not.toContain('resumeCommand') - expect(local.search).not.toHaveBeenCalled() - sshSearch.mockRejectedValue(new Error('SSH relay is not ready')) - await expect(aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host')).rejects.toThrow( - 'SSH relay is not ready' - ) - expect(local.search).not.toHaveBeenCalled() - }) it('routes one runtime environment over its RPC and stamps the answering host', async () => { const local = fakeSearchService() setSessionSearchService(local) @@ -129,7 +106,7 @@ describe('desktop IPC and preload search boundary', () => { expect(await aiVaultApi.searchStatus('runtime:env-1')).toEqual(unavailableSessionSearchStatus()) expect(runtimeSearch).toHaveBeenLastCalledWith('env-1', 'aiVault.searchStatus', {}) }) - it.each(['ssh:ssh-host', 'runtime:env-1', 'all'] as const)( + it.each(['runtime:env-1'] as const)( 'negotiates each actual remote leg once through preload scope %s', async (scope) => { const local = fakeSearchService() @@ -157,7 +134,7 @@ describe('desktop IPC and preload search boundary', () => { expect(remoteHits).toMatchObject([{ agent: 'jcode', source: { presence: 'present' } }]) expect(JSON.stringify(remoteHits)).not.toContain('/host/transcript') expect(JSON.stringify(remoteHits)).not.toContain('resumeCommand') - expect(local.search).toHaveBeenCalledTimes(scope === 'all' ? 1 : 0) + expect(local.search).not.toHaveBeenCalled() expect(scope === 'runtime:env-1' ? sshSearch : runtimeSearch).not.toHaveBeenCalled() } ) @@ -206,34 +183,6 @@ describe('desktop IPC and preload search boundary', () => { expect(sshSearch).not.toHaveBeenCalled() expect(runtimeSearch).not.toHaveBeenCalled() }) - it('merges every enumerated host under the all scope, and still refuses an all status', async () => { - setSessionSearchService(fakeSearchService()) - sshHostInfos.mockReturnValue([{ targetId: 'box' }]) - sshSearch.mockResolvedValue({ - ...searchResults(), - hits: [{ ...searchHit(), sessionId: 'far' }] - }) - const merged = await aiVaultApi.searchSessions({ query: 'needle' }, 'all') - expect(merged).toMatchObject({ - kind: 'results', - hosts: [ - { executionHostId: 'local', outcome: 'searched' }, - { executionHostId: 'ssh:box', outcome: 'searched' } - ] - }) - expect( - merged.kind === 'results' - ? merged.hits.map((hit) => [hit.executionHostId, hit.sessionId]) - : null - ).toEqual([ - ['local', 'host-session'], - ['ssh:box', 'far'] - ]) - // A merged status would have to reconcile six phases into one; it stays refused. - await expect(handlers.get('aiVault:searchStatus')!(null, 'all')).rejects.toThrow( - 'not available for this execution host' - ) - }) it('turns a paired runtime host on and answers with the status it reported', async () => { const local = fakeSearchService() diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 190e917edb97..98826cc59b74 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -23,17 +23,12 @@ import { ALL_EXECUTION_HOSTS_SCOPE, LOCAL_EXECUTION_HOST_ID, parseRoutableExecutionHostId, - toSshExecutionHostId, type ParsedExecutionHost } from '../../shared/execution-host' import { redactStatusForTransport } from '../../shared/ai-vault-search-transport' -import { requestActiveSshSessionSearch } from './ssh' import { clearSessionSearchInService } from '../ai-vault/session-scanner-service-spawn' import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' -import { - getActiveRuntimeAiVaultHostInfosResult, - getActiveSshAiVaultHostInfosResult -} from './ai-vault' +import { getActiveRuntimeAiVaultHostInfosResult } from './ai-vault' import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' import { searchWithStructuredOwners } from '../ai-vault/structured-session-ownership' @@ -169,9 +164,6 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] { executionHostId: LOCAL_EXECUTION_HOST_ID, search: searchLocalSessions } - const sshLegs = getActiveSshAiVaultHostInfosResult().hostInfos.map(({ targetId }) => - remoteHostLeg({ kind: 'ssh', id: toSshExecutionHostId(targetId), targetId }) - ) const runtimeLegs = getActiveRuntimeAiVaultHostInfosResult().hostInfos.map((hostInfo) => remoteHostLeg({ kind: 'runtime', @@ -179,7 +171,7 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] { environmentId: hostInfo.environmentId }) ) - return [localLeg, ...sshLegs, ...runtimeLegs] + return [localLeg, ...runtimeLegs] } function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg { @@ -232,13 +224,6 @@ function remoteSearchClient( host: ParsedExecutionHost, call: RuntimeSessionSearchCall | undefined ): ReturnType | null { - if (host.kind === 'ssh') { - const { targetId } = host - return createSessionSearchClient( - (method, params) => requestActiveSshSessionSearch(targetId, method, params), - 'relay' - ) - } if (host.kind === 'runtime' && call) { const { environmentId } = host return createSessionSearchClient( diff --git a/src/main/ipc/ai-vault-session-title-routing.ts b/src/main/ipc/ai-vault-session-title-routing.ts index cf3ce4cfeef6..5a9d953e0644 100644 --- a/src/main/ipc/ai-vault-session-title-routing.ts +++ b/src/main/ipc/ai-vault-session-title-routing.ts @@ -8,8 +8,6 @@ import { requestedExecutionHostScope } from '../../shared/execution-host' import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' -import { parseAiVaultSessionTitlesResult } from '../ai-vault/session-title-result-validation' -import { requestActiveSshAiVaultSessionTitles } from './ssh' export type RuntimeAiVaultSessionTitleResolver = ( environmentId: string, @@ -25,16 +23,6 @@ export async function resolveAiVaultSessionTitlesByHost( return resolveLocalAiVaultSessionTitles(args.requests) } const parsed = parseRoutableExecutionHostId(executionHostScope) - if (parsed?.kind === 'ssh') { - try { - const result = await requestActiveSshAiVaultSessionTitles(parsed.targetId, { - requests: args.requests - }) - return result === null ? { titles: [] } : parseAiVaultSessionTitlesResult(result) - } catch { - return { titles: [] } - } - } if (parsed?.kind === 'runtime' && resolveRuntime) { return resolveRuntime(parsed.environmentId, args).catch(() => ({ titles: [] })) } diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index ec5269be4203..4e4f20407890 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from 'node:events' import { homedir } from 'node:os' import { join, sep } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +6,6 @@ import type { IFilesystemProvider } from '../providers/types' import type * as CachedSessionListModule from '../ai-vault/cached-session-list' import type * as SessionParseCacheModule from '../ai-vault/session-scanner-parse-cache' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ scanLocalSessions: vi.fn(), @@ -129,275 +127,6 @@ describe('listAiVaultSessions host routing', () => { expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() }) - it('routes SSH scope to only that SSH target', async () => { - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo'] - }) - - expect(mocks.scanLocalSessions).not.toHaveBeenCalled() - expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box') - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( - expect.objectContaining({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - scopePaths: ['/home/ada/repo'] - }) - ) - }) - - it('uses one target-side relay scan when the SSH relay supports it', async () => { - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( - result([session('local', 'remote-session')]) - ) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo'] - }) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - { - limit: undefined, - scopePaths: ['/home/ada/repo'] - }, - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ) - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(scanned.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - id: expect.stringContaining('ssh:dev-box:') - }) - }) - - it('marks oversized project scopes when sending their bounded relay form', async () => { - const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(result([])) - - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths - }) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - { - limit: undefined, - scopePaths: scopePaths.slice(0, 64), - scopePathsTruncated: true - }, - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ) - }) - - it('caps and reports oversized project scopes on the SSH filesystem fallback', async () => { - const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths - }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( - expect.objectContaining({ scopePaths: scopePaths.slice(0, 64) }) - ) - expect(scanned.issues).toContainEqual( - expect.objectContaining({ - kind: 'scope', - message: expect.stringContaining('first 64 project paths') - }) - ) - }) - - it('coalesces concurrent cancellable requests into one scan', async () => { - let resolveRelay: (() => void) | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - () => - new Promise((resolve) => { - resolveRelay = () => resolve(result([])) - }) - ) - const args = { executionHostScope: 'ssh:dev-box' as const } - - const first = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) - const second = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) - await vi.waitFor(() => expect(resolveRelay).toBeDefined()) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) - resolveRelay?.() - await expect(Promise.all([first, second])).resolves.toHaveLength(2) - }) - - it('does not start a second remote crawl after the relay scan budget expires', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box' - }) - - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(scanned.sessions).toEqual([]) - expect(scanned.issues[0]?.message).toContain('timed out') - }) - - it('does not cache a host-level relay failure', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(2) - }) - - it('falls back to the filesystem crawl after a non-timeout relay failure', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue( - new Error('Invalid aiVault.listSessions response') - ) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box' - }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('falls back when a nonempty relay sessions array contains no valid rows', async () => { - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue({ - sessions: [{ id: 42 }], - issues: [], - scannedAt: '2026-07-27T00:00:00.000Z' - }) - - const scanned = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('uses the relay scan without requiring the fallback filesystem provider', async () => { - mocks.getSshFilesystemProvider.mockReturnValue(undefined) - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( - result([session('local', 'remote-session')]) - ) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box' - }) - - expect(scanned.sessions).toHaveLength(1) - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - }) - - it('merges local plus connected SSH targets for all hosts', async () => { - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - expect.any(Object), - expect.objectContaining({ timeoutMs: 15_000 }) - ) - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) - }) - - it('merges paired runtime servers for all hosts', async () => { - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [ - { - environmentId: 'remote-server', - executionHostId: 'runtime:remote-server' - } - ], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( - 'remote-server', - { - executionHostScope: 'runtime:remote-server' - }, - expect.objectContaining({ timeoutMs: expect.any(Number) }) - ) - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([ - 'runtime:remote-server', - 'ssh:dev-box', - 'local' - ]) - }) - - it('keeps local and SSH results when runtime host discovery fails', async () => { - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => { - throw new Error('runtime store is invalid') - }, - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled() - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) - expect(result.issues).toEqual([ - expect.objectContaining({ - agent: 'codex', - path: 'runtime environments', - message: 'runtime store is invalid' - }) - ]) - }) - - it('keeps SSH results when the local scan itself throws', async () => { - // Why: `all` awaits every leg together, so an unguarded local throw (parse - // cache load, WSL home resolution) would discard every host's sessions. - mocks.scanLocalSessions.mockRejectedValue(new Error('session parse cache is corrupt')) - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box']) - expect(result.issues).toEqual([ - expect.objectContaining({ - executionHostId: 'local', - kind: 'host', - path: 'this computer', - message: 'session parse cache is corrupt' - }) - ]) - }) - - it('keeps local results when SSH host discovery fails', async () => { - mocks.getActiveSshAiVaultHostInfos.mockImplementation(() => { - throw new Error('relay session map is unavailable') - }) - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local']) - expect(result.issues).toEqual([ - expect.objectContaining({ - agent: 'codex', - path: 'SSH hosts', - message: 'relay session map is unavailable' - }) - ]) - }) - it('keeps direct runtime host scans on the normal runtime timeout', async () => { registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => [], @@ -418,86 +147,6 @@ describe('listAiVaultSessions host routing', () => { {} ) }) - - it('returns a scan issue for a disconnected SSH target', async () => { - mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null) - mocks.getSshFilesystemProvider.mockReturnValue(undefined) - - const result = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:disconnected' - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toMatchObject([ - { - executionHostId: 'ssh:disconnected', - agent: 'codex', - path: 'disconnected' - } - ]) - }) - - it('keeps host scope in the cache key', async () => { - await _internals.listAiVaultSessions({ executionHostScope: 'local' }) - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - }) - - it('caches completed SSH scans by host and workspace scope', async () => { - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo-a', '/home/ada/repo-b'] - }) - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo-b', '/home/ada/repo-a'] - }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - }) - - it('serves lower SSH depths from a larger completed scan', async () => { - const base = { executionHostScope: 'ssh:dev-box' as const, scopePaths: ['/home/ada/repo'] } - await _internals.listAiVaultSessions({ ...base, limit: 1000 }) - await _internals.listAiVaultSessions({ ...base, limit: 250 }) - await _internals.listAiVaultSessions({ ...base, limit: 500 }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - }) - - it('threads renderer cancellation into the SSH relay request', async () => { - let relaySignal: AbortSignal | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId, _params, options: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - relaySignal = options.signal - options.signal?.addEventListener( - 'abort', - () => { - const error = new Error('cancelled') - error.name = 'AbortError' - reject(error) - }, - { once: true } - ) - }) - ) - registerAiVaultHandlers() - const event = { sender: Object.assign(new EventEmitter(), { id: 7 }) } - const pending = getIpcHandler('aiVault:listSessions')(event, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan-1' - }) - await vi.waitFor(() => expect(relaySignal).toBeDefined()) - - await getIpcHandler('aiVault:cancelListSessions')(event, { requestToken: 'scan-1' }) - - expect(relaySignal?.aborted).toBe(true) - // Superseded scans resolve because Electron logs every rejected handler. - await expect(pending).resolves.toMatchObject({ cancelled: true, sessions: [] }) - }) }) describe('resolveAiVaultSessionTitles host routing', () => { @@ -519,22 +168,6 @@ describe('resolveAiVaultSessionTitles host routing', () => { expect(mocks.scanLocalSessions).not.toHaveBeenCalled() }) - it('routes SSH identities to the transcript-owning relay', async () => { - mocks.requestActiveSshAiVaultSessionTitles.mockResolvedValue(titles) - - await expect( - _internals.resolveAiVaultSessionTitles({ - executionHostScope: 'ssh:dev-box', - requests - }) - ).resolves.toEqual(titles) - - expect(mocks.requestActiveSshAiVaultSessionTitles).toHaveBeenCalledWith('dev-box', { - requests - }) - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - }) - it('routes runtime identities to the paired runtime host', async () => { const resolveRuntimeAiVaultSessionTitles = vi.fn().mockResolvedValue(titles) registerAiVaultHandlers({ resolveRuntimeAiVaultSessionTitles }) @@ -643,14 +276,6 @@ function getPrepareSessionResumeHandler(): ( return registration[1] } -function getIpcHandler(channel: string): (...args: unknown[]) => unknown { - const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel) - if (!registration) { - throw new Error(`${channel} was not registered`) - } - return registration[1] -} - describe('listAiVaultSubagentSessions gating', () => { const claudeRoot = join(homedir(), '.claude', 'projects') @@ -860,45 +485,6 @@ describe('deleteAiVaultSession', () => { expect(mocks.ipcHandle).toHaveBeenCalledWith('aiVault:deleteSession', expect.any(Function)) }) - - // Observes the real multi-host invalidation seam (not mocked) end-to-end: - // deleting must clear this module's own cache, not only the two shared caches. - it('clears the multi-host scan-result cache after a real delete', async () => { - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - // Second list is a cache hit, so only one scan so far. - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - - mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - await _internals.deleteAiVaultSession(args) - - // Cache was cleared, so the next list re-scans instead of serving the stale - // list that still contained the deleted session. - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) - }) - - // Generation guard: a scan already in flight when the delete lands must not - // write its pre-delete result back into the multi-host cache. - it('does not let an in-flight multi-host scan repopulate the cache after a delete', async () => { - let resolveScan: (value: AiVaultListResult) => void = () => {} - mocks.scanRemoteAiVaultSessions.mockReturnValueOnce( - new Promise((resolve) => { - resolveScan = resolve - }) - ) - const inFlight = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - await _internals.deleteAiVaultSession(args) - - resolveScan(result([session('ssh:dev-box', 'stale-session')])) - await inFlight - - // The late scan must not have cached its stale result: the next list re-scans. - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) - }) }) function hostInfo(targetId: string) { @@ -910,14 +496,6 @@ function hostInfo(targetId: string) { } } -/** Mirrors the multiplexer's typed timeout: callers branch on the code, not on - * the message text. */ -function relayTimeoutError(): Error { - return Object.assign(new Error('Request "aiVault.listSessions" timed out after 130000ms'), { - code: SSH_MUX_REQUEST_TIMEOUT_CODE - }) -} - function result(sessions: AiVaultSession[]): AiVaultListResult { return { sessions, issues: [], scannedAt: new Date().toISOString() } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 06f6caebdb2d..f34c1214dabc 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -12,7 +12,6 @@ import { cancelledAiVaultListResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' -import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' import type { AiVaultDeleteSessionArgs } from '../../shared/ai-vault-session-deletion' import { describeAiVaultScanError } from '../../shared/ai-vault-scan-error-message' @@ -32,10 +31,8 @@ import { parseRoutableExecutionHostId, requestedExecutionHostScope, toRuntimeExecutionHostId, - toSshExecutionHostId, type ExecutionHostScope } from '../../shared/execution-host' -import { getActiveSshAiVaultHostInfos } from './ssh' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery' import { @@ -97,11 +94,15 @@ async function listAiVaultSessions( includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true }) const depth = requestedAiVaultSessionDepth(args) + const afterFingerprint = + parseRoutableExecutionHostId(executionHostScope)?.kind === 'runtime' + ? args?.afterFingerprint + : undefined // Why: every renderer request carries its own cancellation signal, so // coalescing has to survive them — the coordinator hands all same-key callers // one scan and only aborts it once every one of them has cancelled. return scanCoordinator.run({ - key: JSON.stringify({ key, depth }), + key: JSON.stringify({ key, depth, afterFingerprint }), force: args?.force, signal: options.signal, start: (scanSignal) => { @@ -114,6 +115,7 @@ async function listAiVaultSessions( depth, scopePaths, force: args?.force === true, + afterFingerprint, scan }) } @@ -133,27 +135,9 @@ async function scanAiVaultSessionsByHostScope( } if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() - const sshHosts = getActiveSshAiVaultHostInfosResult() - const runtimeResults = [ - ...(runtimeHosts.issue ? [runtimeHosts.issue] : []), - ...(sshHosts.issue ? [sshHosts.issue] : []) - ] + const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] const scannedResults = await Promise.all([ scanLocalAiVaultSessionsAsIssue(args, signal), - ...sshHosts.hostInfos.map((hostInfo) => - scanHostLegWithCache({ - cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`, - depth, - scopePaths, - force: args?.force === true, - scan: () => - scanSshAiVaultSessions(hostInfo.targetId, args, { - signal, - timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.sshScan, - relayTimeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.sshScanRelay - }) - }) - ), ...runtimeHosts.hostInfos.map((hostInfo) => scanHostLegWithCache({ cacheKey: `${cacheKey}|${hostInfo.executionHostId}`, @@ -164,7 +148,7 @@ async function scanAiVaultSessionsByHostScope( scanRuntimeAiVaultSessions({ hostInfo, scanner: handlerOptions.scanRuntimeAiVaultSessions, - listArgs: args, + listArgs: args ? { ...args, afterFingerprint: undefined } : undefined, options: { signal, timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.runtimeScan } }) }) @@ -178,9 +162,6 @@ async function scanAiVaultSessionsByHostScope( } const parsed = parseRoutableExecutionHostId(executionHostScope) - if (parsed?.kind === 'ssh') { - return scanSshAiVaultSessions(parsed.targetId, args, { signal }) - } if (parsed?.kind === 'runtime') { return scanRuntimeAiVaultSessions({ hostInfo: { @@ -207,15 +188,6 @@ export function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryRe }) } -export function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ - targetId: string -}> { - return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, { - path: 'SSH hosts', - fallbackMessage: 'SSH hosts are unavailable.' - }) -} - // Why: the SSH legs already degrade to an issue row so one bad host can't take // the shared Promise.all down; the local leg can throw too (parse-cache load, // WSL home resolution, scanner service supervision) and would otherwise discard diff --git a/src/main/ipc/ephemeral-vm-managed-ssh.test.ts b/src/main/ipc/ephemeral-vm-managed-ssh.test.ts new file mode 100644 index 000000000000..8166861ab163 --- /dev/null +++ b/src/main/ipc/ephemeral-vm-managed-ssh.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, args: unknown) => unknown +const { handlers, connect, getPath } = vi.hoisted(() => ({ + handlers: new Map(), + connect: vi.fn(), + getPath: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath }, + ipcMain: { + handle: (channel: string, handler: Handler) => handlers.set(channel, handler), + removeHandler: vi.fn() + } +})) +vi.mock('../ephemeral-vm-runtime-ssh', () => ({ + connectRuntimeOwnedSshTarget: connect, + disconnectRuntimeOwnedSshTarget: vi.fn(async () => {}), + removeRuntimeOwnedSshTarget: vi.fn(async () => {}) +})) +vi.mock('./runtime-environments', () => ({ invalidateRuntimeEnvironmentTransport: vi.fn() })) + +import type { Store } from '../persistence' +import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' +import { registerEphemeralVmHandlers } from './ephemeral-vm' + +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const sshResult = { + schemaVersion: 1, + connection: { + type: 'ssh', + projectRoot: '/workspace/repo', + target: { label: 'Sandbox', host: 'sandbox.example.com', port: 22, username: 'root' } + } +} + +function setupManagedSshRecipe(): { userDataPath: string } { + const userDataPath = tempDir('orca-vm-managed-user-data-') + const repoPath = tempDir('orca-vm-managed-repo-') + getPath.mockReturnValue(userDataPath) + const script = (name: string, body: string): string => { + const scriptPath = join(repoPath, name) + writeFileSync(scriptPath, body) + return JSON.stringify(`"${process.execPath}" "${scriptPath}"`) + } + const printResult = `console.log(${JSON.stringify(JSON.stringify(sshResult))})` + writeFileSync( + join(repoPath, 'orca.yaml'), + [ + 'environmentRecipes:', + ' - id: cloud-sandbox', + ' name: Cloud Sandbox', + ` create: ${script('start.js', printResult)}`, + ` suspend: ${script('suspend.js', '')}`, + ` resume: ${script('resume.js', printResult)}`, + ' destroy: none' + ].join('\n') + ) + connect.mockReset() + connect.mockImplementation(async ({ runtimeId }: { runtimeId: string }) => ({ + targetId: `runtime-ssh-${runtimeId}`, + target: { id: `runtime-ssh-${runtimeId}` }, + environmentId: 'env-managed-vm' + })) + const repo = { id: 'repo-1', path: repoPath, displayName: 'Repo', badgeColor: '#000', addedAt: 0 } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only getRepo, getRepos and getSettings. + const store = { + getRepo: (id: string) => (id === repo.id ? repo : null), + getRepos: () => [repo], + getSettings: () => ({}) + } as unknown as Store + registerEphemeralVmHandlers(store) + return { userDataPath } +} + +async function provision(): Promise { + return handlers.get('ephemeralVm:provision')?.(null, { + repoId: 'repo-1', + recipeId: 'cloud-sandbox' + }) +} + +it("records the managed server that serves a recipe VM's SSH host", async () => { + setupManagedSshRecipe() + expect(await provision()).toMatchObject({ + ok: true, + connectionType: 'ssh', + environmentId: 'env-managed-vm', + runtime: { connectionMode: 'ssh', runtimeEnvironmentId: 'env-managed-vm' } + }) +}) + +it("reconnects a resumed VM's SSH host instead of asking for a pairing code", async () => { + const { userDataPath } = setupManagedSshRecipe() + const provisioned = await provision() + const [runtime] = listEphemeralVmRuntimes(userDataPath) + expect(provisioned).toMatchObject({ ok: true, runtime: { id: runtime.id } }) + const runtimeId = runtime.id + await handlers.get('ephemeralVm:attachWorkspace')?.(null, { runtimeId, workspaceId: 'ws-1' }) + await handlers.get('ephemeralVm:suspendWorkspace')?.(null, { workspaceId: 'ws-1' }) + connect.mockClear() + + const resumed = await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { workspaceId: 'ws-1' }) + + expect(connect).toHaveBeenCalledTimes(1) + expect(resumed).toMatchObject({ + status: 'running', + connectionMode: 'ssh', + runtimeEnvironmentId: 'env-managed-vm' + }) +}) diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index 261c8050df87..95ab7bf6e64b 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -9,10 +9,8 @@ import { getEphemeralVmRecipeResultConnection, getEphemeralVmRecipeResultPairingCode } from '../../shared/ephemeral-vm-recipes' -import { - removeEnvironment, - updateEnvironmentFromPairingCode -} from '../../shared/runtime-environment-store' +import { updateEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { releaseEphemeralVmRuntimeEnvironments } from '../ephemeral-vm-runtime-environment-release' import { cleanupEphemeralVmRuntime, resumeEphemeralVmRuntime, @@ -105,9 +103,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { runtimeId: runtime.id }) } - if (result.ok && runtime.runtimeEnvironmentId) { + if (result.ok) { try { - removeEnvironment(userDataPath, runtime.runtimeEnvironmentId) + await releaseEphemeralVmRuntimeEnvironments(userDataPath, runtime) } catch { // Cleanup of provider resources matters more than hiding a stale local // environment row; users can still remove that manually. @@ -215,7 +213,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { if (!result.ok) { throw new Error(result.error) } - if (!result.skipped && runtime.runtimeEnvironmentId) { + const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) + // Why: an SSH VM's server is reached through its target, so it has no pairing to refresh. + if (!result.skipped && connection.type !== 'ssh' && runtime.runtimeEnvironmentId) { const pairingCode = getEphemeralVmRecipeResultPairingCode(result.runtime.recipeResult) if (!pairingCode) { throw new Error('Resume result did not include an Orca Server pairing code.') @@ -225,7 +225,6 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { }) invalidateRuntimeEnvironmentTransport(runtime.runtimeEnvironmentId) } - const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) if (!result.skipped && connection.type === 'ssh') { try { const ssh = await connectRuntimeOwnedSshTarget({ @@ -234,7 +233,8 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { }) return updateEphemeralVmRuntimeStatus(userDataPath, result.runtime.id, { connectionMode: 'ssh', - sshTargetId: ssh.targetId + sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { runtimeEnvironmentId: ssh.environmentId } : {}) }) } catch (error) { updateEphemeralVmRuntimeStatus(userDataPath, result.runtime.id, { diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index bb7ba5c8d66d..5e8ec75b1966 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -53,6 +53,8 @@ export type EphemeralVmProvisionIpcResult = connectionType: 'ssh' runtime: EphemeralVmRuntimeRecord sshTargetId: string + /** The managed Orca server the host runs; its workspace lives there. */ + environmentId?: string expectedRefHead?: string stderr: string warnings: EphemeralVmRecipeResultWarning[] @@ -213,7 +215,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin app.getPath('userData'), result.runtime.id, { - sshTargetId: ssh.targetId + sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { runtimeEnvironmentId: ssh.environmentId } : {}) } ) return { @@ -221,6 +224,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin connectionType: 'ssh', runtime, sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { environmentId: ssh.environmentId } : {}), ...(expectedRefHead ? { expectedRefHead } : {}), stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr), warnings: getEphemeralVmRecipeResultWarnings(result.start.result) diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 371a182c5060..223a448e775e 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -1,4 +1,6 @@ +import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as gitRunner from '../git/runner' import { handlers, store, @@ -6,6 +8,11 @@ import { commitChangesMock, bulkDiscardChangesMock, getSshGitProviderMock, + resolveCommitMessageSettingsMock, + generatePullRequestFieldsFromContextMock, + getPullRequestDraftContextMock, + resolveHostedReviewBodyForGenerationMock, + loadPullRequestLinkedIssueMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -200,3 +207,167 @@ describe('registerFilesystemHandlers', () => { expect(sshCommitMock).not.toHaveBeenCalled() }) }) + +describe('registerFilesystemHandlers pull request field generation', () => { + beforeEach(() => { + resetFilesystemIpcMocks() + // Reset module-level auth cache so each test starts with a fresh dirty + // flag — prevents stale worktree data from a prior test's cache rebuild. + invalidateAuthorizedRootsCache() + }) + + describe('git:generatePullRequestFields linked issue', () => { + const PULL_REQUEST_CONTEXT = { + base: 'main', + branch: 'feature/ai', + branchChangedByPreparation: false, + commitSummary: 'a1b2c3d Add generation', + changeSummary: 'README.md | 2 +-', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + const PULL_REQUEST_ARGS = { base: 'main', title: '', body: '', draft: false } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + + beforeEach(() => { + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') + getPullRequestDraftContextMock.mockResolvedValue(PULL_REQUEST_CONTEXT) + generatePullRequestFieldsFromContextMock.mockResolvedValue({ success: true, fields: {} }) + }) + + it('enriches the local pull-request context with a validated worktree linked issue', async () => { + const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` + const linkedIssueDetails = { + provider: 'github', + number: 123, + title: 'Improve PR generation', + description: 'Include issue context.' + } + loadPullRequestLinkedIssueMock.mockResolvedValue(linkedIssueDetails) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId, + provider: 'github' + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { + ...PULL_REQUEST_CONTEXT, + linkedIssue: 123, + provider: 'github', + linkedIssueDetails + }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + it('enriches the SSH pull-request context from host meta using the remote path', async () => { + const worktreeId = 'repo-1::/remote/repo' + getSshGitProviderMock.mockReturnValue({ + exec: vi.fn(), + executeCommitMessagePlan: vi.fn() + }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: '/remote/repo', + worktreeId, + connectionId: 'conn-1' + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 77 }, + params, + expect.objectContaining({ kind: 'remote' }) + ) + }) + + it('ignores a pull-request worktree id that does not own the requested path', async () => { + const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` + }) + + expect(getWorktreeMeta).not.toHaveBeenCalled() + // Why: without the length guard the property assertion passes vacuously on `undefined`, + // so an unrelated early return would read as "enrichment correctly suppressed". + expect(generatePullRequestFieldsFromContextMock.mock.calls).toHaveLength(1) + expect(generatePullRequestFieldsFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + + it('forwards PR-context command limits through local and SSH adapters', async () => { + const localGitExec = vi + .spyOn(gitRunner, 'gitExecFileAsync') + .mockResolvedValue({ stdout: '', stderr: '' }) + const sshExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + getSshGitProviderMock.mockReturnValue({ + exec: sshExec, + executeCommitMessagePlan: vi.fn() + }) + getPullRequestDraftContextMock.mockImplementation(async (execute) => { + await execute(['show-ref', '--verify'], { maxBuffer: 123, timeoutMs: 456 }) + await execute(['legacy-probe'], { timeout: 789 }) + return PULL_REQUEST_CONTEXT + }) + + try { + registerFilesystemHandlers(store as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(localGitExec).toHaveBeenCalledWith(['show-ref', '--verify'], { + cwd: WORKTREE_FEATURE_PATH, + maxBuffer: 123, + timeout: 456 + }) + expect(localGitExec).toHaveBeenCalledWith(['legacy-probe'], { + cwd: WORKTREE_FEATURE_PATH, + timeout: 789 + }) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + + expect(sshExec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', { + timeoutMs: 456 + }) + expect(sshExec).toHaveBeenCalledWith(['legacy-probe'], '/remote/repo', { + timeoutMs: 789 + }) + } finally { + localGitExec.mockRestore() + } + }) + }) +}) diff --git a/src/main/ipc/filesystem-pull-request-field-generation.test.ts b/src/main/ipc/filesystem-pull-request-field-generation.test.ts deleted file mode 100644 index b12e5d2b0109..000000000000 --- a/src/main/ipc/filesystem-pull-request-field-generation.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as gitRunner from '../git/runner' -import { - handlers, - store, - WORKTREE_FEATURE_PATH, - resolveCommitMessageSettingsMock, - generatePullRequestFieldsFromContextMock, - getPullRequestDraftContextMock, - resolveHostedReviewBodyForGenerationMock, - loadPullRequestLinkedIssueMock, - getSshGitProviderMock, - resetFilesystemIpcMocks -} from './filesystem-test-harness' - -vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) -vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) -vi.mock( - '../wsl-unc-delete', - async () => (await import('./filesystem-test-harness')).wslUncDeleteMock -) -vi.mock( - '../crash-reporting/crash-breadcrumb-store', - async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock -) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) -vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock -) -vi.mock( - '../git/check-ignored-paths', - async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock -) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock -) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) -vi.mock( - '../text-generation/commit-message-text-generation', - async () => (await import('./filesystem-test-harness')).textGenerationModuleMock -) -vi.mock( - '../text-generation/pull-request-context', - async () => (await import('./filesystem-test-harness')).pullRequestContextMock -) -vi.mock( - '../source-control/pull-request-template', - async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock -) -vi.mock( - '../source-control/pull-request-linked-issue', - async () => (await import('./filesystem-test-harness')).pullRequestLinkedIssueMock -) - -import { registerFilesystemHandlers } from './filesystem' -import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' - -describe('registerFilesystemHandlers', () => { - beforeEach(() => { - resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. - invalidateAuthorizedRootsCache() - }) - - describe('git:generatePullRequestFields linked issue', () => { - const PULL_REQUEST_CONTEXT = { - base: 'main', - branch: 'feature/ai', - branchChangedByPreparation: false, - commitSummary: 'a1b2c3d Add generation', - changeSummary: 'README.md | 2 +-', - patch: '+hello', - currentTitle: '', - currentBody: '', - currentDraft: false - } - const PULL_REQUEST_ARGS = { base: 'main', title: '', body: '', draft: false } - const params = { agentId: 'codex', model: 'gpt-5.4-mini' } - - beforeEach(() => { - resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) - resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') - getPullRequestDraftContextMock.mockResolvedValue(PULL_REQUEST_CONTEXT) - generatePullRequestFieldsFromContextMock.mockResolvedValue({ success: true, fields: {} }) - }) - - it('enriches the local pull-request context with a validated worktree linked issue', async () => { - const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` - const linkedIssueDetails = { - provider: 'github', - number: 123, - title: 'Improve PR generation', - description: 'Include issue context.' - } - loadPullRequestLinkedIssueMock.mockResolvedValue(linkedIssueDetails) - const linkedStore = { - ...store, - getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) - } - - registerFilesystemHandlers(linkedStore as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: WORKTREE_FEATURE_PATH, - worktreeId, - provider: 'github' - }) - - expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( - { - ...PULL_REQUEST_CONTEXT, - linkedIssue: 123, - provider: 'github', - linkedIssueDetails - }, - params, - expect.objectContaining({ kind: 'local' }) - ) - }) - - it('enriches the SSH pull-request context from host meta using the remote path', async () => { - const worktreeId = 'repo-1::/remote/repo' - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn(), - executeCommitMessagePlan: vi.fn() - }) - const linkedStore = { - ...store, - getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) - } - - registerFilesystemHandlers(linkedStore as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: '/remote/repo', - worktreeId, - connectionId: 'conn-1' - }) - - expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( - { ...PULL_REQUEST_CONTEXT, linkedIssue: 77 }, - params, - expect.objectContaining({ kind: 'remote' }) - ) - }) - - it('ignores a pull-request worktree id that does not own the requested path', async () => { - const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) - - registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: WORKTREE_FEATURE_PATH, - worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` - }) - - expect(getWorktreeMeta).not.toHaveBeenCalled() - // Why: without the length guard the property assertion passes vacuously on `undefined`, - // so an unrelated early return would read as "enrichment correctly suppressed". - expect(generatePullRequestFieldsFromContextMock.mock.calls).toHaveLength(1) - expect(generatePullRequestFieldsFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( - 'linkedIssue' - ) - }) - - it('forwards PR-context command limits through local and SSH adapters', async () => { - const localGitExec = vi - .spyOn(gitRunner, 'gitExecFileAsync') - .mockResolvedValue({ stdout: '', stderr: '' }) - const sshExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - getSshGitProviderMock.mockReturnValue({ - exec: sshExec, - executeCommitMessagePlan: vi.fn() - }) - getPullRequestDraftContextMock.mockImplementation(async (execute) => { - await execute(['show-ref', '--verify'], { maxBuffer: 123, timeoutMs: 456 }) - await execute(['legacy-probe'], { timeout: 789 }) - return PULL_REQUEST_CONTEXT - }) - - try { - registerFilesystemHandlers(store as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: WORKTREE_FEATURE_PATH - }) - - expect(localGitExec).toHaveBeenCalledWith(['show-ref', '--verify'], { - cwd: WORKTREE_FEATURE_PATH, - maxBuffer: 123, - timeout: 456 - }) - expect(localGitExec).toHaveBeenCalledWith(['legacy-probe'], { - cwd: WORKTREE_FEATURE_PATH, - timeout: 789 - }) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: '/remote/repo', - connectionId: 'conn-1' - }) - - expect(sshExec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', { - timeoutMs: 456 - }) - expect(sshExec).toHaveBeenCalledWith(['legacy-probe'], '/remote/repo', { - timeoutMs: 789 - }) - } finally { - localGitExec.mockRestore() - } - }) - }) -}) diff --git a/src/main/ipc/filesystem-quick-open-options.integration.test.ts b/src/main/ipc/filesystem-quick-open-options.integration.test.ts index 904fe8bcb282..f908d2527f7f 100644 --- a/src/main/ipc/filesystem-quick-open-options.integration.test.ts +++ b/src/main/ipc/filesystem-quick-open-options.integration.test.ts @@ -2,22 +2,17 @@ import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises' import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, expect, it } from 'vitest' import type { Store } from '../persistence' import { listQuickOpenFiles } from './filesystem-list-files' -import { listFilesWithRg } from '../../relay/fs-handler-list-files' import { searchQuickOpenFilePaths } from './filesystem-search-file-paths' -import { bundledRipgrepCommand } from '../ripgrep/bundled-ripgrep-path' -import { configureRelayBundledRipgrep } from '../../relay/relay-bundled-ripgrep' const fixtures: string[] = [] -beforeEach(() => configureRelayBundledRipgrep(bundledRipgrepCommand())) afterEach(async () => { - configureRelayBundledRipgrep(undefined) await Promise.all(fixtures.splice(0).map((path) => rm(path, { recursive: true, force: true }))) }) -it('honors inherited ignores, opt-in links, cycles, retargets and relay parity in real processes', async () => { +it('honors inherited ignores, opt-in links, cycles, and retargets in real processes', async () => { const parent = await mkdtemp(join(tmpdir(), 'orca-quick-open-options-')) fixtures.push(parent) await mkdir(join(parent, '.git')) @@ -53,8 +48,6 @@ it('honors inherited ignores, opt-in links, cycles, retargets and relay parity i undefined, options ) - const relay = await listFilesWithRg(root, [], options) - expect(local.sort()).toEqual(relay.sort()) expect(local.includes('ignored.txt')).toBe(options.includeIgnored) expect(local.includes('linked/linked.md')).toBe(options.followSymlinks) expect(local).not.toContain('cycle/apps/api/.env') @@ -122,13 +115,6 @@ it('validates recent membership independently of top32, ignores, exclusions and getSettings: () => ({}), getFolderWorkspaces: () => [] } as unknown as Store - const top = await listFilesWithRg(root, [], { - searchQuery: 'file', - maxResults: 32, - includeIgnored: false - }) - expect(top).toHaveLength(32) - expect(top).not.toContain('src/file059.ts') const candidates = [ 'src/file059.ts', 'deleted.ts', @@ -148,17 +134,17 @@ it('validates recent membership independently of top32, ignores, exclusions and undefined, options ) - const relay = await listFilesWithRg(root, ['excluded'], { - ...options, - maxResults: candidates.length - }) expect(local).toEqual(['src/file059.ts']) - expect(relay).toEqual(local) - const broad = await listFilesWithRg(root, ['excluded'], { - ...options, - includeIgnored: true, - maxResults: candidates.length - }) + const broad = await listQuickOpenFiles( + root, + store, + [join(root, 'excluded')], + undefined, + candidates.length, + undefined, + undefined, + { ...options, includeIgnored: true } + ) expect(broad.sort()).toEqual(['ignored.ts', 'src/file059.ts']) await mkdir(join(root, 'large')) for (let index = 0; index < 20_020; index += 1) { @@ -180,11 +166,4 @@ it('validates recent membership independently of top32, ignores, exclusions and includeIgnored: false }) ).toEqual([missing]) - expect( - await listFilesWithRg(root, [], { - candidatePaths: [missing], - includeIgnored: false, - maxResults: 1 - }) - ).toEqual([missing]) }, 60_000) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index ebd39dc24447..867a4fb86869 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -26,7 +26,7 @@ import { import { parseWslPath, toWindowsWslPath } from '../wsl' import { resolveAuthorizedPath } from './filesystem-auth' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' -import { QuickOpenSubprocessPathAccumulator } from '../../shared/quick-open-listing-limits' +import { QuickOpenSubprocessPathAccumulator } from '../../shared/quick-open-subprocess-path-accumulator' import { bundledRipgrepUnavailableError } from '../ripgrep/bundled-ripgrep-path' import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' diff --git a/src/main/ipc/managed-host-status-publication.test.ts b/src/main/ipc/managed-host-status-publication.test.ts index 0f529e539e7d..6807215984b7 100644 --- a/src/main/ipc/managed-host-status-publication.test.ts +++ b/src/main/ipc/managed-host-status-publication.test.ts @@ -16,7 +16,6 @@ vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ const { clearManagedServerNotes, publishHostServerStatus } = await import('./runtime-environment-managed-tunnel') -const { getPublicSshState, relayStateOverrides } = await import('./ssh-renderer-broadcast') const { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } = await import('../ssh/ssh-host-server-status') @@ -33,7 +32,6 @@ function connection(status: 'connected' | 'disconnected') { describe('publishing a verified managed host status', () => { beforeEach(() => { vi.clearAllMocks() - relayStateOverrides.clear() clearSshHostServerStatus('ssh-1') clearSshHostServerStatus('ssh-2') }) @@ -53,13 +51,6 @@ describe('publishing a verified managed host status', () => { ) }) - it('refreshes a retained override so later reads also forget the deferral', () => { - relayStateOverrides.set('ssh-1', connection('connected')) - publishHostServerStatus('ssh-1', ready) - expect(relayStateOverrides.get('ssh-1')?.managedServer).toEqual(ready) - expect(getPublicSshState('ssh-1')?.managedServer).toEqual(ready) - }) - it('preserves disconnected contact instead of inventing a connection', () => { mocks.getState.mockReturnValue(connection('disconnected')) publishHostServerStatus('ssh-1', ready) diff --git a/src/main/ipc/managed-orcad-actions.ts b/src/main/ipc/managed-orcad-actions.ts index a356605f10c3..f6e2dd8c513d 100644 --- a/src/main/ipc/managed-orcad-actions.ts +++ b/src/main/ipc/managed-orcad-actions.ts @@ -9,8 +9,10 @@ import { updateManagedOrcadEnvironment } from '../ssh/orcad-runtime-lifecycle' import type { ManagedServerActions } from '../runtime/managed-server-actions-registry' -import type { ExecutionHostId } from '../../shared/execution-host' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' +import { + retireRemovedRuntimeEnvironment, + type RemovedServerStore +} from './runtime-environment-removal-cleanup' export type ManagedOrcadActionOptions = { getUserDataPath: () => string @@ -20,8 +22,8 @@ export type ManagedOrcadActionOptions = { clearHostServerNotes: (sshTargetId: string, environmentId: string) => void /** Drops the SSH host's stale managed-server state once its server is unlinked. */ clearHostServerStatus: (sshTargetId: string) => void - /** Drops the unlinked server's workspace session partition. */ - forgetHostSession: (hostId: ExecutionHostId) => void + /** Holds what an unlinked server leaves in the profile. */ + store: RemovedServerStore } export function createManagedOrcadActions( @@ -32,11 +34,7 @@ export function createManagedOrcadActions( isActiveEnvironment: (environmentId: string) => options.getActiveEnvironmentId() === environmentId, retireLocalState: (environmentId: string) => - retireRemovedRuntimeEnvironment( - environmentId, - options.invalidateTransport, - options.forgetHostSession - ) + retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport, options.store) } return { status: (selector) => getManagedOrcadRuntimeStatus(userDataPath(), selector), diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts index 223be8000706..2baa16e22952 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts @@ -28,7 +28,11 @@ const { registerOrcadRuntimeMaintenanceHandlers } = const invalidateTransport = vi.fn() const clearHostServerStatus = vi.fn() const clearHostServerNotes = vi.fn() -const forgetHostSession = vi.fn() +const store = { + removeWorkspaceSessionHost: vi.fn(), + getRemoteLayoutSnapshot: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() +} function handler(channel: string): (_event: unknown, args: unknown) => Promise { const registration = mocks.handle.mock.calls.find(([name]) => name === channel) @@ -47,7 +51,7 @@ describe('managed orcad maintenance IPC', () => { invalidateTransport, clearHostServerStatus, clearHostServerNotes, - forgetHostSession + store }) }) @@ -124,7 +128,7 @@ describe('managed orcad maintenance IPC', () => { expect(policy.isActiveEnvironment('active-environment')).toBe(true) expect(policy.isActiveEnvironment('e-1')).toBe(false) policy.retireLocalState('e-1') - expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, forgetHostSession) + expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, store) // The SSH host stops naming the unlinked server without waiting for a reconnect. expect(clearHostServerStatus).toHaveBeenCalledWith('ssh-1') }) diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index 5c95b3e27de9..b5ed72243975 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -3,7 +3,6 @@ import { toSshExecutionHostId } from '../../shared/execution-host' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { LocalPtyProvider } from '../providers/local-pty-provider' -import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' import { registerPtyHandlers, registerSshPtyProvider, @@ -209,28 +208,6 @@ describe('registerPtyHandlers', () => { } }) - it('answers unknown for an earlier relay PTY once the target is in plain SSH mode', async () => { - // Why: rung D never saw that PTY; it may still run on the earlier relay. - const connectionId = 'ssh-plain-1' - const ptyId = `ssh:${connectionId}@@prior-relay-pty` - setLocalPtyProvider(new LocalPtyProvider()) - const openShell = vi.fn(async () => { - throw new Error('no shell in this test') - }) - const mode = { reason: 'no_runtime' as const, message: 'plain' } - registerSshPtyProvider( - connectionId, - new SshPlainShellPtyProvider(connectionId, openShell, mode, true, 1) - ) - const controller = registerAgentClaimController() - try { - await expect(controller.probePtyLiveness(ptyId)).resolves.toBeNull() - await expect(controller.attach(ptyId)).resolves.toBe(false) - } finally { - unregisterSshPtyProvider(connectionId) - } - }) - it('answers unknown for remote-scoped ids without consulting local providers', async () => { // Why: a locally routed provider would answer confidently — and wrongly — // for a PTY that lives on a remote Orca host. diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 86fe3381f2bf..50516ec3398c 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -159,7 +159,7 @@ describe('registerPtyHandlers', () => { leafId, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, + mayCreate: false, origin: 'spawn' }) }) @@ -519,7 +519,7 @@ describe('registerPtyHandlers', () => { leafId, ptyId: 'pty-shared', startupCwd: '/tmp', - hostAdmittedMembership: true, + mayCreate: false, origin: 'spawn' }) }) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 30ef457a81d3..0cee922e8091 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -542,7 +542,7 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-1@@relay-pty', - hostAdmittedMembership: true, + mayCreate: false, origin: 'spawn' }, 'ssh:ssh-1' diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index 6af9334293a1..d273b4566ab8 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -202,7 +202,7 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-reattach-ok@@relay-pty', - hostAdmittedMembership: true, + mayCreate: false, origin: 'reattach' }, 'ssh:ssh-reattach-ok' diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts index 76b6dd9ce87b..386e74a8fccd 100644 --- a/src/main/ipc/pty-spawn-placement-threading.test.ts +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -170,7 +170,7 @@ describe('pty spawn placement threading', () => { leafId: LEAF, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, + mayCreate: false, placement: SPLIT, origin: 'spawn' }) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 71a9a42bf518..deb361e2ab23 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -150,7 +150,8 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { tabId, leafId, ptyId: ctx.result.id, - hostAdmittedMembership: true, + // The runtime wrote the pane before spawning; a pane gone by now was closed meanwhile. + mayCreate: false, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), ...(expectedSourceBinding ? { expectedSourceBinding } : {}), diff --git a/src/main/ipc/remote-layout-snapshot-recorder.test.ts b/src/main/ipc/remote-layout-snapshot-recorder.test.ts new file mode 100644 index 000000000000..6f82cdd6f77c --- /dev/null +++ b/src/main/ipc/remote-layout-snapshot-recorder.test.ts @@ -0,0 +1,194 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { scanSourceTree, stripComments } from '../../shared/source-scan/source-tree-scan' +import { + forgetRemoteLayoutSnapshot, + recordRemoteLayoutFrame, + type RemoteLayoutSnapshotStore +} from './remote-layout-snapshot-recorder' + +const METHOD = 'session.tabs.subscribeAll' + +function memoryStore() { + const snapshots = new Map() + const saves: ('now' | 'later')[] = [] + const store: RemoteLayoutSnapshotStore = { + getRemoteLayoutSnapshot: (environmentId) => snapshots.get(environmentId), + setRemoteLayoutSnapshot: (environmentId, snapshot, save) => { + saves.push(save) + if (snapshot) { + snapshots.set(environmentId, snapshot) + } else { + snapshots.delete(environmentId) + } + } + } + return { store, snapshots, saves } +} + +function tabs(worktree: string, title = 'shell') { + return { + worktree, + publicationEpoch: 'epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [{ type: 'terminal', id: `${worktree}-tab`, title }] + } +} + +function frame(result: unknown, runtimeId = 'server-1'): RuntimeRpcResponse { + return { id: 'stream', ok: true, result, _meta: { runtimeId } } +} + +function workspacesOf(snapshots: Map, environmentId = 'env') { + return snapshots.get(environmentId)?.workspaces +} + +describe('remote layout snapshot recorder', () => { + it('replaces the whole entry on each census, dropping worktrees it no longer lists', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('a'), tabs('b')] }) + ) + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('b', 'renamed')] }, 'server-2') + ) + + expect(snapshots.get('env')).toMatchObject({ + serverRuntimeId: 'server-2', + workspaces: { b: tabs('b', 'renamed') } + }) + expect(workspacesOf(snapshots)).not.toHaveProperty('a') + }) + + it('replaces one worktree on an update and drops it on a removal', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('a'), tabs('b')] }) + ) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'updated', ...tabs('a', 'vim') })) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim'), b: tabs('b') }) + + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'updated', ...tabs('b'), tabs: [], removed: true }) + ) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim') }) + }) + + it('keeps neither retirement proofs nor one-shot navigation', () => { + const { store, snapshots } = memoryStore() + const proof = { parentTabId: 'a-tab', leafId: 'leaf', ptyId: 'pty', terminal: 'term' } + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [{ ...tabs('a'), retiredTerminalSurfaces: [proof] }] }) + ) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a') }) + + // A capable client is sent only new proofs, so an update's list is a delta, here empty. + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ + type: 'updated', + ...tabs('a', 'vim'), + retiredTerminalSurfaces: [], + navigationIntent: 'follow' + }) + ) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim') }) + }) + + it('saves at once only when tabs or groups change; title ticks ride the next save', () => { + const { store, saves } = memoryStore() + const census = (snapshots: unknown[]) => frame({ type: 'snapshots', snapshots }) + const update = (result: Record) => frame({ type: 'updated', ...result }) + recordRemoteLayoutFrame(store, 'env', METHOD, census([tabs('a')])) + recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking.'))) + recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking..'))) + const twoTabs = { + ...tabs('a'), + tabs: [...tabs('a').tabs, { type: 'terminal', id: 'second', title: 'shell' }] + } + recordRemoteLayoutFrame(store, 'env', METHOD, update(twoTabs)) + const grouped = { + ...twoTabs, + tabGroups: [{ id: 'g', activeTabId: null, tabOrder: ['second'] }] + } + recordRemoteLayoutFrame(store, 'env', METHOD, update(grouped)) + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + update({ ...grouped, tabGroups: [{ id: 'g', activeTabId: 'second', tabOrder: ['second'] }] }) + ) + recordRemoteLayoutFrame(store, 'env', METHOD, update({ ...tabs('a'), tabs: [], removed: true })) + expect(saves).toEqual(['now', 'later', 'later', 'now', 'now', 'later', 'now']) + }) + + it('keeps each server in its own entry', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame( + store, + 'one', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('a')] }) + ) + recordRemoteLayoutFrame(store, 'two', METHOD, frame({ type: 'snapshots', snapshots: [] })) + expect(workspacesOf(snapshots, 'one')).toEqual({ a: tabs('a') }) + expect(workspacesOf(snapshots, 'two')).toEqual({}) + }) + + it('records nothing from other streams, failures, ends or unreadable frames', () => { + const { store, saves } = memoryStore() + const census = { type: 'snapshots', snapshots: [tabs('a')] } + recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribe', frame(census)) + recordRemoteLayoutFrame(store, 'env', 'terminal.multiplex', frame(census)) + recordRemoteLayoutFrame(store, 'env', METHOD, { + id: 'stream', + ok: false, + error: { code: 'method_not_found', message: 'nope' }, + _meta: { runtimeId: 'server-1' } + }) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'end' })) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'snapshots', snapshots: [{}] })) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'updated', worktree: 'a' })) + expect(saves).toEqual([]) + }) + + it('forgets a removed server', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'snapshots', snapshots: [] })) + forgetRemoteLayoutSnapshot(store, 'env') + expect(snapshots.has('env')).toBe(false) + }) + + it('is the only code that writes the snapshot', () => { + const src = join(__dirname, '../..') + const writers = scanSourceTree(src) + .filter(({ source }) => stripComments(source).includes('setRemoteLayoutSnapshot(')) + .map(({ relativePath }) => relativePath) + expect(writers.sort()).toEqual([ + 'main/ipc/remote-layout-snapshot-recorder.ts', + 'main/persistence/loading-store/remote-layout-snapshot-persistence.ts' + ]) + }) +}) diff --git a/src/main/ipc/remote-layout-snapshot-recorder.ts b/src/main/ipc/remote-layout-snapshot-recorder.ts new file mode 100644 index 000000000000..de115a9871ed --- /dev/null +++ b/src/main/ipc/remote-layout-snapshot-recorder.ts @@ -0,0 +1,104 @@ +// The one writer of the display-only server layout snapshot (design 6.3, D3). It records the +// `session.tabs.subscribeAll` frames main already relays to the window, so no server receives a +// new request. Nothing reads the snapshot yet. + +import type { + RemoteLayoutSnapshot, + RemoteLayoutSnapshotWorkspace +} from '../../shared/remote-layout-snapshot' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-session-contracts' + +export type RemoteLayoutSnapshotStore = { + getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined + setRemoteLayoutSnapshot( + environmentId: string, + snapshot: RemoteLayoutSnapshot | null, + save: 'now' | 'later' + ): void +} + +type SessionTabsFrame = + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | (RuntimeMobileSessionTabsResult & { type: 'updated'; removed?: true }) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Checks the identity fields only; the rest is the server's published contract. +function isSessionTabsResult(value: unknown): value is RuntimeMobileSessionTabsResult { + return isRecord(value) && typeof value.worktree === 'string' && Array.isArray(value.tabs) +} + +function isSessionTabsFrame(value: unknown): value is SessionTabsFrame { + if (!isRecord(value)) { + return false + } + return value.type === 'snapshots' + ? Array.isArray(value.snapshots) && value.snapshots.every(isSessionTabsResult) + : value.type === 'updated' && isSessionTabsResult(value) +} + +function displayed({ + navigationIntent: _command, + retiredTerminalSurfaces: _proofs, + ...workspace +}: RuntimeMobileSessionTabsResult): RemoteLayoutSnapshotWorkspace { + return workspace +} + +// Which tabs exist and where; titles and status change far more often and can wait for a save. +function structure(workspace: RemoteLayoutSnapshotWorkspace | undefined): string { + return JSON.stringify([ + workspace?.tabs.map((tab) => tab.id), + workspace?.tabGroups?.map((group) => [group.id, group.tabOrder]), + workspace?.tabGroupLayout + ]) +} + +/** Called for every relayed stream frame; only the window's all-worktree tab stream is recorded. */ +export function recordRemoteLayoutFrame( + store: RemoteLayoutSnapshotStore, + environmentId: string, + method: string, + response: RuntimeRpcResponse +): void { + if (method !== 'session.tabs.subscribeAll' || !response.ok) { + return + } + const frame = response.result + if (!isSessionTabsFrame(frame)) { + return + } + const recorded = { receivedAt: Date.now(), serverRuntimeId: response._meta.runtimeId } + if (frame.type === 'snapshots') { + // A census replaces the whole entry: a worktree missing from it is gone. + const workspaces = Object.fromEntries( + frame.snapshots.map((snapshot) => [snapshot.worktree, displayed(snapshot)]) + ) + store.setRemoteLayoutSnapshot(environmentId, { ...recorded, workspaces }, 'now') + return + } + const { type: _type, removed, ...result } = frame + const { [frame.worktree]: previous, ...others } = + store.getRemoteLayoutSnapshot(environmentId)?.workspaces ?? {} + if (removed) { + store.setRemoteLayoutSnapshot(environmentId, { ...recorded, workspaces: others }, 'now') + return + } + const workspace = displayed(result) + store.setRemoteLayoutSnapshot( + environmentId, + { ...recorded, workspaces: { ...others, [frame.worktree]: workspace } }, + structure(previous) === structure(workspace) ? 'later' : 'now' + ) +} + +/** A removed server's snapshot goes with it. */ +export function forgetRemoteLayoutSnapshot( + store: RemoteLayoutSnapshotStore, + environmentId: string +): void { + store.setRemoteLayoutSnapshot(environmentId, null, 'now') +} diff --git a/src/main/ipc/repos/repos-changed-notification.ts b/src/main/ipc/repos/repos-changed-notification.ts index 3428fc87eb4a..43aa0ae35a2a 100644 --- a/src/main/ipc/repos/repos-changed-notification.ts +++ b/src/main/ipc/repos/repos-changed-notification.ts @@ -12,9 +12,9 @@ export function setRepoRemoteClientNotifier(notifier: RepoRemoteClientNotifier): repoRemoteClientNotifier = notifier } -export function notifyReposChanged(mainWindow: BrowserWindow): void { +export function notifyReposChanged(mainWindow: BrowserWindow | null): void { wakeFolderRepoGitUpgradeWatch() - if (!mainWindow.isDestroyed()) { + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('repos:changed') } // Why: paired clients only refetch a remote catalog on this event; without it a diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 013cdef9ea3a..7a3b88850a24 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -105,12 +105,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => { const environment = resolveEnvironment(getUserDataPath(), args.selector) if (store.getSettings().activeRuntimeEnvironmentId === environment.id) { - throw new Error('Choose another Active Server in Advanced before removing this server.') + throw new Error( + 'This server is the default host for new projects. Choose another one in Advanced before removing it.' + ) } const removed = removeEnvironment(getUserDataPath(), args.selector) - void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, (hostId) => - store.removeWorkspaceSessionHost(hostId) - ) + void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, store) closeLegacySelectorTransport(args.selector, removed.id) return { removed: redactRuntimeEnvironment(removed) } } diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts index 2f6b4ccb2136..9a4f96549359 100644 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ b/src/main/ipc/runtime-environment-managed-tunnel.ts @@ -19,7 +19,7 @@ import { import { getSshHostServerStatus, setSshHostServerStatus } from '../ssh/ssh-host-server-status' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' -import { broadcastSshState, relayStateOverrides } from './ssh-renderer-broadcast' +import { broadcastSshState } from './ssh-renderer-broadcast' export async function resolveManagedRuntimeEnvironment( userDataPath: string, @@ -100,12 +100,8 @@ export function publishResolvedChangedHostStatus(target: SshTarget, environmentI export function publishHostServerStatus(targetId: string, status: SshManagedServerStatus): void { setSshHostServerStatus(targetId, status) - const override = relayStateOverrides.get(targetId) - if (override) { - relayStateOverrides.set(targetId, { ...override, managedServer: status }) - } // Only a host with a connection state has a status line to refresh. - const state = relayStateOverrides.get(targetId) ?? connectionManager?.getState(targetId) + const state = connectionManager?.getState(targetId) if (state) { broadcastSshState(getCurrentMainWindow, targetId, { ...state, managedServer: status }) } diff --git a/src/main/ipc/runtime-environment-removal-cleanup.test.ts b/src/main/ipc/runtime-environment-removal-cleanup.test.ts index 261a24f652d0..d3f40eeee239 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.test.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.test.ts @@ -10,9 +10,14 @@ vi.mock('../browser/browser-route-partition-storage-retirement', () => ({ const { retireRemovedRuntimeEnvironment } = await import('./runtime-environment-removal-cleanup') describe('retiring a removed runtime environment', () => { - it('forgets the server’s workspace session partition under its runtime host id', async () => { - const forgetHostSession = vi.fn() - await retireRemovedRuntimeEnvironment('env 1', vi.fn(), forgetHostSession) - expect(forgetHostSession).toHaveBeenCalledWith('runtime:env%201') + it('forgets the server’s session partition and its layout snapshot', async () => { + const store = { + removeWorkspaceSessionHost: vi.fn(), + getRemoteLayoutSnapshot: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() + } + await retireRemovedRuntimeEnvironment('env 1', vi.fn(), store) + expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith('runtime:env%201') + expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith('env 1', null, 'now') }) }) diff --git a/src/main/ipc/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environment-removal-cleanup.ts index c1961c137d90..2e67142824cd 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.ts @@ -3,16 +3,27 @@ import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/bro import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect' import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + forgetRemoteLayoutSnapshot, + type RemoteLayoutSnapshotStore +} from './remote-layout-snapshot-recorder' + +/** What a removed server leaves in the profile. */ +export type RemovedServerStore = RemoteLayoutSnapshotStore & { + /** Drops the unlinked server's workspace session partition. */ + removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void +} /** Retires a removed server's client-side state; resolves once its transport is invalidated. */ export function retireRemovedRuntimeEnvironment( environmentId: string, invalidateTransport: (environmentId: string) => Promise | void, - forgetHostSession?: (hostId: ExecutionHostId) => void + store: RemovedServerStore ): Promise { clearRuntimeEnvironmentCapabilityEvidence(environmentId) // Why: listings enumerate session partitions as known hosts, so a kept one names a dead server. - forgetHostSession?.(toRuntimeExecutionHostId(environmentId)) + store.removeWorkspaceSessionHost(toRuntimeExecutionHostId(environmentId)) + forgetRemoteLayoutSnapshot(store, environmentId) clearRuntimeEnvironmentManualDisconnect(environmentId) const retiring = Promise.resolve(invalidateTransport(environmentId)) // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes diff --git a/src/main/ipc/runtime-environment-removal-storage.test.ts b/src/main/ipc/runtime-environment-removal-storage.test.ts index 012df15f47a2..f21d1ac5e29a 100644 --- a/src/main/ipc/runtime-environment-removal-storage.test.ts +++ b/src/main/ipc/runtime-environment-removal-storage.test.ts @@ -48,7 +48,11 @@ describe('runtime environment removal storage clearing', () => { clearStorageMock.mockResolvedValue({ clearedPartitions: ['persist:one'], livePartitions: [] }) registerRuntimeEnvironmentConnectivityHandlers({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune. - store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never, + store: { + getSettings: () => ({}), + removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() + } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => teardown }) @@ -69,7 +73,11 @@ describe('runtime environment removal storage clearing', () => { .mockResolvedValueOnce({ clearedPartitions: ['persist:one'], livePartitions: [] }) registerRuntimeEnvironmentConnectivityHandlers({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune. - store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never, + store: { + getSettings: () => ({}), + removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() + } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => Promise.resolve() }) diff --git a/src/main/ipc/runtime-environment-subscription-handlers.ts b/src/main/ipc/runtime-environment-subscription-handlers.ts index 0ca81f8bf92d..a579b9c6546d 100644 --- a/src/main/ipc/runtime-environment-subscription-handlers.ts +++ b/src/main/ipc/runtime-environment-subscription-handlers.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { randomUUID } from 'node:crypto' import { resolveEnvironment } from '../../shared/runtime-environment-store' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' @@ -23,8 +24,14 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { getUserDataPath: () => string remoteRuntimeSubscriptions: Map pendingSubscriptions: Map + recordLayoutFrame: ( + environmentId: string, + method: string, + response: RuntimeRpcResponse + ) => void }): void { - const { getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions } = args + const { getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions, recordLayoutFrame } = + args ipcMain.handle( 'runtimeEnvironments:subscribe', async ( @@ -149,6 +156,10 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { subscriptionId, ...payload }) + // After the send, so recording never delays or changes what the window gets. + if (payload.type === 'response') { + recordLayoutFrame(environment.id, args.method, payload.response) + } } }, onClose: () => { diff --git a/src/main/ipc/runtime-environment-subscription-identity.test.ts b/src/main/ipc/runtime-environment-subscription-identity.test.ts index 8712b5f1cc3c..3f106f8e1f33 100644 --- a/src/main/ipc/runtime-environment-subscription-identity.test.ts +++ b/src/main/ipc/runtime-environment-subscription-identity.test.ts @@ -46,7 +46,8 @@ beforeEach(() => { registerRuntimeEnvironmentSubscriptionHandlers({ getUserDataPath: () => '/profile', remoteRuntimeSubscriptions, - pendingSubscriptions + pendingSubscriptions, + recordLayoutFrame: vi.fn() }) }) afterEach(() => { diff --git a/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts b/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts new file mode 100644 index 000000000000..de5c9255bcef --- /dev/null +++ b/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' + +const mocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn(), subscribe: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle, on: mocks.on } })) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: () => ({ id: 'env', pairingRevision: 1, createdAt: 1 }) +})) +vi.mock('./runtime-environment-transport-routing', () => ({ + subscribeRuntimeEnvironment: mocks.subscribe +})) +import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' +import { + registerRuntimeEnvironmentSubscriptionHandlers, + type PendingRuntimeSubscription, + type RetainedRemoteRuntimeSubscription +} from './runtime-environment-subscription-handlers' + +type Callbacks = Parameters[5] + +const sender = { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn() +} +const recordLayoutFrame = vi.fn() +const census = { + id: 'stream', + ok: true as const, + result: { type: 'snapshots', snapshots: [] }, + _meta: { runtimeId: 'server' } +} + +async function open(method: string): Promise { + let callbacks: Callbacks | undefined + mocks.subscribe.mockImplementationOnce( + async (...parameters: Parameters) => { + callbacks = parameters[5] + return { requestId: 'request', sendBinary: vi.fn(() => true), close: vi.fn() } + } + ) + const subscribe = mocks.handle.mock.calls.find( + ([name]) => name === 'runtimeEnvironments:subscribe' + )![1] + await subscribe({ sender }, { selector: 'env', method, subscriptionId: method }) + return callbacks! +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.subscribe.mockReset() + registerRuntimeEnvironmentSubscriptionHandlers({ + getUserDataPath: () => '/profile', + remoteRuntimeSubscriptions: new Map(), + pendingSubscriptions: new Map(), + recordLayoutFrame + }) +}) + +it('records the window’s own stream after forwarding it unchanged, opening nothing new', async () => { + const callbacks = await open('session.tabs.subscribeAll') + callbacks.onEvent({ type: 'response', response: census }) + + // The window's subscription is the only request the server sees. + expect(mocks.subscribe).toHaveBeenCalledOnce() + expect(mocks.subscribe.mock.calls[0]?.[2]).toBe('session.tabs.subscribeAll') + expect(sender.send).toHaveBeenCalledExactlyOnceWith('runtimeEnvironments:subscriptionEvent', { + subscriptionId: 'session.tabs.subscribeAll', + type: 'response', + response: census + }) + expect(recordLayoutFrame).toHaveBeenCalledExactlyOnceWith( + 'env', + 'session.tabs.subscribeAll', + census + ) + expect(sender.send.mock.invocationCallOrder[0]).toBeLessThan( + recordLayoutFrame.mock.invocationCallOrder[0]! + ) +}) + +it('offers the recorder only frames the window was sent', async () => { + const callbacks = await open('session.tabs.subscribeAll') + callbacks.onEvent({ type: 'binary', bytes: new Uint8Array([1]) }) + callbacks.onEvent({ type: 'error', code: 'lost', message: 'lost' }) + // A replaced transport's late frame reaches neither the window nor the snapshot. + advanceRuntimeEnvironmentTransportGeneration('env') + sender.send.mockClear() + callbacks.onEvent({ type: 'response', response: census }) + expect(sender.send).not.toHaveBeenCalled() + expect(recordLayoutFrame).not.toHaveBeenCalled() +}) diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 695eb511bcb7..3746780476a2 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -83,6 +83,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } beforeEach(() => { @@ -91,6 +92,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 4296904b2bd9..3f5eb01e8d66 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -101,6 +101,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } beforeEach(() => { @@ -109,6 +110,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -285,10 +287,11 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) expect(activeRuntimeEnvironmentId).toBeNull() expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) - // A removed server's session partition goes too, so listings stop naming it as a host. + // A removed server's session partition and layout snapshot go too. expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith( `runtime:${encodeURIComponent(added.environment.id)}` ) + expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith(added.environment.id, null, 'now') expect(JSON.stringify(removed)).not.toContain('device-token') expect(await list(null, undefined)).toEqual([]) }) @@ -476,7 +479,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { ) expect(() => remove(null, { selector: added.environment.id })).toThrow( - 'Choose another Active Server in Advanced' + 'Choose another one in Advanced before removing it.' ) expect(activeRuntimeEnvironmentId).toBe(added.environment.id) expect(store.updateSettings).not.toHaveBeenCalled() diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index e3005c2b6fef..49736d300074 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -91,6 +91,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } beforeEach(() => { @@ -99,6 +100,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index ab3346a6ddee..20c537486433 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -6,6 +6,7 @@ import { import { app, ipcMain } from 'electron' import { listEnvironments } from '../../shared/runtime-environment-store' import type { Store } from '../persistence' +import { recordRemoteLayoutFrame } from './remote-layout-snapshot-recorder' import { isRuntimeEnvironmentManuallyDisconnected, registerRuntimeEnvironmentConnectivityHandlers, @@ -147,11 +148,13 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { invalidateTransport: invalidateRuntimeEnvironmentTransport, clearHostServerStatus: clearPublishedManagedServer, clearHostServerNotes: clearManagedServerNotes, - forgetHostSession: (hostId) => store.removeWorkspaceSessionHost(hostId) + store }) registerRuntimeEnvironmentSubscriptionHandlers({ getUserDataPath, remoteRuntimeSubscriptions, - pendingSubscriptions + pendingSubscriptions, + recordLayoutFrame: (environmentId, method, response) => + recordRemoteLayoutFrame(store, environmentId, method, response) }) } diff --git a/src/main/ipc/settings-delayed-response.test.ts b/src/main/ipc/settings-delayed-response.test.ts new file mode 100644 index 000000000000..f1d79107913d --- /dev/null +++ b/src/main/ipc/settings-delayed-response.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../persistence/loading-store/profile-state-maintenance-fixture' + +const { + handleMock, + setMainUiLanguageMock, + applyElectronProxySettingsMock, + applyAgentStatusHooksEnabledMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + setMainUiLanguageMock: vi.fn(), + applyElectronProxySettingsMock: vi.fn(), + applyAgentStatusHooksEnabledMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/test/user-data') }, + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + ipcMain: { handle: handleMock, on: vi.fn() }, + nativeTheme: { themeSource: 'system' } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) +vi.mock('../ghostty/index', () => ({ previewGhosttyImport: vi.fn() })) +vi.mock('../warp-themes', () => ({ previewWarpThemeImport: vi.fn() })) +vi.mock('../network/proxy-settings', () => ({ + applyElectronProxySettings: applyElectronProxySettingsMock +})) +vi.mock('../browser/browser-session-proxy', () => ({ + applyBrowserSessionProxies: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: { listProfiles: vi.fn(() => []) } +})) +vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() })) +vi.mock('../ai-vault-search/session-search-enablement', () => ({ + applySessionSearchSettingsChange: vi.fn() +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock +})) +vi.mock('../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootsForRepos: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: vi.fn() })) +vi.mock('../i18n/main-i18n', () => ({ setMainUiLanguage: setMainUiLanguageMock })) + +import { registerSettingsHandlers } from './settings' + +beforeEach(() => { + handleMock.mockClear() + setMainUiLanguageMock.mockReset().mockResolvedValue('en') + applyElectronProxySettingsMock.mockReset().mockResolvedValue({ source: 'settings' }) + applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([]) +}) + +const event = { sender: { id: 1 } } + +function settingsWriter() { + const handler = handleMock.mock.calls.find(([channel]) => channel === 'settings:set')?.[1] + if (typeof handler !== 'function') { + throw new Error('Missing settings:set handler') + } + return handler +} + +it('returns current settings when a language change finishes after a newer theme write', async () => { + const { store, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'system', uiLanguage: 'en' }) + await store.flushPendingOrThrowAsync() + registerSettingsHandlers(store) + const write = settingsWriter() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + setMainUiLanguageMock.mockImplementationOnce(async () => { + started.resolve() + await release.promise + return 'ko' + }) + + const first = write(event, { theme: 'dark', uiLanguage: 'ko' }) + await started.promise + await write(event, { theme: 'light' }) + release.resolve() + const reply = await first + await store.flushPendingOrThrowAsync() + + expect(store.getSettings().theme).toBe('light') + expect(readState().settings.theme).toBe('light') + expect(reply.theme).toBe('light') +}) + +it('returns current settings when hook reconciliation finishes after a newer theme write', async () => { + const { store, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'system', agentStatusHooksEnabled: false }) + await store.flushPendingOrThrowAsync() + registerSettingsHandlers(store) + const write = settingsWriter() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + applyAgentStatusHooksEnabledMock.mockImplementationOnce(async () => { + started.resolve() + await release.promise + return [] + }) + + const first = write(event, { theme: 'dark', agentStatusHooksEnabled: true }) + await started.promise + await write(event, { theme: 'light' }) + release.resolve() + const reply = await first + await store.flushPendingOrThrowAsync() + + expect(store.getSettings().theme).toBe('light') + expect(readState().settings.theme).toBe('light') + expect(reply.theme).toBe('light') + expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledWith( + true, + expect.objectContaining({ theme: 'dark' }), + expect.any(Object) + ) +}) + +it('returns current settings when proxy reconciliation finishes after a newer theme write', async () => { + const { store, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'system', httpProxyUrl: '' }) + await store.flushPendingOrThrowAsync() + registerSettingsHandlers(store) + const write = settingsWriter() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + applyElectronProxySettingsMock.mockImplementationOnce(async () => { + started.resolve() + await release.promise + return { source: 'settings' } + }) + + const first = write(event, { theme: 'dark', httpProxyUrl: 'http://proxy.example:8080' }) + await started.promise + await write(event, { theme: 'light' }) + release.resolve() + const reply = await first + await store.flushPendingOrThrowAsync() + + expect(store.getSettings().theme).toBe('light') + expect(readState().settings.theme).toBe('light') + expect(reply.theme).toBe('light') +}) diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index b1b4a8cdea47..80757d55c922 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -279,7 +279,7 @@ describe('registerSettingsHandlers', () => { ) expect(() => handler(settingsInvokeEvent, { environmentId: 42 as never })).toThrow( - 'Invalid Active Server preference' + 'Invalid default host for new projects' ) expect(() => handler(settingsInvokeEvent, { environmentId: 'does-not-exist' })).toThrow( 'Runtime environment not found' diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 8442bfd86829..cd9fd873e0a3 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -302,7 +302,7 @@ export function registerSettingsHandlers( }) } - return result + return store.getSettings() }) ipcMain.handle( @@ -310,7 +310,7 @@ export function registerSettingsHandlers( (event, args: { environmentId?: unknown }): GlobalSettings => { const requestedEnvironmentId = args?.environmentId if (requestedEnvironmentId !== null && typeof requestedEnvironmentId !== 'string') { - throw new Error('Invalid Active Server preference') + throw new Error('Invalid default host for new projects') } const requestedId = requestedEnvironmentId?.trim() || null const environmentId = diff --git a/src/main/ipc/ssh-active-relay-sessions.test.ts b/src/main/ipc/ssh-active-relay-sessions.test.ts deleted file mode 100644 index e7784131188b..000000000000 --- a/src/main/ipc/ssh-active-relay-sessions.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { activeSessions, getActiveSshHostHomeDirectory } from './ssh-active-relay-sessions' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -import { - resolveWorktreeRemovalHome, - resolveWorktreeRemovalRoute -} from '../worktree-removal-execution-host-route' - -const TARGET = 'target-home' - -function sessionReporting(remoteHome: string | null): never { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getRemoteHomeDirectory is the only member the home lookup calls on a session. - return { getRemoteHomeDirectory: () => remoteHome } as never -} - -afterEach(() => { - activeSessions.delete(TARGET) - unregisterSshGitProvider(TARGET) -}) - -describe('getActiveSshHostHomeDirectory', () => { - it('reports the home the session read on its host', () => { - activeSessions.set(TARGET, sessionReporting('/srv/homes/alice')) - - expect(getActiveSshHostHomeDirectory(TARGET)).toBe('/srv/homes/alice') - }) - - it('reports an absent session as unknown', () => { - expect(getActiveSshHostHomeDirectory(TARGET)).toBeNull() - }) - - it('is wired into worktree removal, so an SSH delete asks the host', () => { - // Pins the module-scope registration: without it the route silently answers - // `homePath: null` forever and the host home stops protecting anything. - activeSessions.set(TARGET, sessionReporting('/srv/homes/alice')) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the provider is registered only so the SSH route resolves; the home answer comes from the session, not from git. - registerSshGitProvider(TARGET, {} as never) - - expect(resolveWorktreeRemovalHome(resolveWorktreeRemovalRoute(`ssh:${TARGET}`))).toEqual({ - kind: 'executionHost', - homePath: '/srv/homes/alice' - }) - }) -}) diff --git a/src/main/ipc/ssh-active-relay-sessions.ts b/src/main/ipc/ssh-active-relay-sessions.ts deleted file mode 100644 index 3c12496f7d4f..000000000000 --- a/src/main/ipc/ssh-active-relay-sessions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { - setDirectSshAuthorityResolver, - setSshActiveMultiplexerResolver -} from '../ssh/ssh-target-registry' -import { setWorktreeRemovalSshHostHomeResolver } from '../worktree-removal-execution-host-route' - -// One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). -export const activeSessions = new Map() - -// Why at module scope: this resolver is pure state lookup with no handler lifecycle, so -// installing it on import keeps it correct even before registerSshHandlers runs. -setSshActiveMultiplexerResolver( - (connectionId) => activeSessions.get(connectionId)?.getMux() ?? undefined -) - -/** - * The `$HOME` the SSH host reported, or `null` while no session has resolved it. - * - * `null` means unknown, never "same as this client's home" — callers must keep - * their path-shape guards rather than fall back to `os.homedir()`. - */ -export function getActiveSshHostHomeDirectory(targetId: string): string | null { - return activeSessions.get(targetId)?.getRemoteHomeDirectory() ?? null -} - -setWorktreeRemovalSshHostHomeResolver(getActiveSshHostHomeDirectory) - -setDirectSshAuthorityResolver((targetId) => activeSessions.has(targetId)) diff --git a/src/main/ipc/ssh-advertised-url-refresh.ts b/src/main/ipc/ssh-advertised-url-refresh.ts deleted file mode 100644 index 927b7017ba31..000000000000 --- a/src/main/ipc/ssh-advertised-url-refresh.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow } from 'electron' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { getConnectionIdsForWorktree } from '../ports/ssh-advertised-url-enrichment' -import { activeSessions } from './ssh-active-relay-sessions' -import { persistedStore } from './ssh-ipc-context' -import { broadcastDetectedPorts, broadcastPortForwards } from './ssh-renderer-broadcast' - -let advertisedUrlWatcherUnsubscribe: (() => void) | null = null - -export function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null): void { - advertisedUrlWatcherUnsubscribe?.() - // Why: SSH port scans only emit on raw host/port/PID changes, but a terminal can print the advertised URL later, so the watcher must also refresh the renderer. - advertisedUrlWatcherUnsubscribe = advertisedUrlWatcher.onDidChange(({ worktreeId }) => { - if (!persistedStore) { - return - } - for (const targetId of getConnectionIdsForWorktree(persistedStore, worktreeId)) { - const session = activeSessions.get(targetId) - if (!session) { - continue - } - const scanner = session.getPortScanner() - if (scanner) { - // Why: watcher changes can arrive before the next SSH scan refreshes listener PIDs, so don't validate PIDs against cached scanner rows. - broadcastDetectedPorts(getMainWindow, targetId, scanner.getDetectedPorts(targetId), { - validatePid: false - }) - } - broadcastPortForwards(getMainWindow, targetId) - } - }) -} - -export function unregisterAdvertisedUrlRefresh(): void { - advertisedUrlWatcherUnsubscribe?.() - advertisedUrlWatcherUnsubscribe = null -} diff --git a/src/main/ipc/ssh-app-shutdown.test.ts b/src/main/ipc/ssh-app-shutdown.test.ts index 5842f37fb08d..ea96fc51eb21 100644 --- a/src/main/ipc/ssh-app-shutdown.test.ts +++ b/src/main/ipc/ssh-app-shutdown.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,227 +21,19 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { getActiveMultiplexer } from './ssh' -import { - beginSshShutdown, - SSH_SHUTDOWN_BUDGET_MS, - type SshShutdownResult -} from './ssh-shutdown-drain' +import { beginSshShutdown } from './ssh-shutdown-drain' import type { SshTarget } from '../../shared/ssh-types' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { mockSshStore, mockConnectionManager, mockMux, mockPortForwardManager } = mocks +const { mockSshStore, mockConnectionManager } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { handlers, mockStore, mockWindow } = harness + const { handlers } = harness beforeEach(harness.reset) - it('detaches active SSH sessions during app shutdown without terminating recovery', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockMux.dispose.mockClear() - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - - await beginSshShutdown() - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(mockConnectionManager.disconnectAll).toHaveBeenCalled() - }) - - it('detaches every lease in memory before a slow forward removal can cross the final flush', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - let releaseForwards!: () => void - vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementationOnce( - () => - new Promise((resolve) => { - releaseForwards = resolve - }) - ) - vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - const shutdown = beginSshShutdown() - - // Why asserted with no await: the committed quit path calls store.flushAsync() on the very next - // line, so anything not already in memory at this instant can never reach the final snapshot. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached') - // Why 'detached' and never 'terminated': the app is letting go of the lease, not proving the - // remote shell died. Those PTYs keep running for the next attach. - expect(mockStore.markSshRemotePtyLeasesForShutdown).not.toHaveBeenCalledWith( - 'ssh-1', - 'terminated' - ) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - - releaseForwards() - await shutdown - }) - - it('repeats no state transition when shutdown is begun twice', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - const first = beginSshShutdown() - const second = beginSshShutdown() - - expect(second).toBe(first) - await Promise.all([first, second]) - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledExactlyOnceWith( - 'ssh-1', - 'detached' - ) - }) - - it('detaches the remaining sessions and still returns when one session throws mid-transition', async () => { - const targets: Record = { - 'ssh-1': { id: 'ssh-1', label: 'A', host: 'a.example.com', port: 22, username: 'deploy' }, - 'ssh-2': { id: 'ssh-2', label: 'B', host: 'b.example.com', port: 22, username: 'deploy' } - } - mockSshStore.getTarget.mockImplementation((id: string) => targets[id] ?? null) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockImplementation((targetId: string) => ({ - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0 - })) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-2' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() - // Why webContents.send: quit destroys the renderer, and that is what makes broadcastEmptyLists - // throw out of the pre-pass for whichever session reaches it first. - mockWindow.webContents.send.mockImplementation((_channel: string, payload: unknown) => { - if ((payload as { targetId?: string } | undefined)?.targetId === 'ssh-1') { - throw new Error('Object has been destroyed') - } - }) - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - // Why not-throw rather than a resolved promise: the caller is a non-async will-quit listener, so - // a synchronous throw escapes it and skips killAllPty, the watchers and store.flushAsync() — the - // very flush that persists the detached leases this pre-pass just staged. - let shutdown!: Promise - expect(() => { - shutdown = beginSshShutdown() - }).not.toThrow() - // Why asserted before the await: the real flush starts on the next synchronous line. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-2', 'detached') - - const result = await shutdown - expect( - result.errors.some( - (error) => error instanceof Error && error.message === 'Object has been destroyed' - ) - ).toBe(true) - }) - - it('reports the target and phase left unfinished when the shutdown budget expires', async () => { - vi.useFakeTimers() - try { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - // Why never resolved, on every call: a forward whose child never reports exit is exactly the - // case the shared deadline exists for, and it must not get a fresh budget per phase. - vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementation( - () => new Promise(() => {}) - ) - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - let settled = false - const shutdown = beginSshShutdown().then((value) => { - settled = true - return value - }) - // Why the whole budget and not a millisecond more: the entire drain/join/drain sequence has to - // fit inside one deadline, so advancing exactly that far must be enough to settle it. Asserting - // the flag rather than awaiting keeps a per-phase budget a failure instead of a hang. - await vi.advanceTimersByTimeAsync(SSH_SHUTDOWN_BUDGET_MS) - expect(settled).toBe(true) - const result = await shutdown - - expect(result.unfinished).toContainEqual({ targetId: 'ssh-1', phase: 'drain' }) - // Why no final-drain entry: the budget was already spent, so no later phase was awaited at all. - expect(result.unfinished.some((entry) => entry.phase === 'final-drain')).toBe(false) - // Why the lease still stands: the drain timing out says nothing about the remote PTYs, and the - // pre-pass already recorded the only thing that was ever provable. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached') - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - } finally { - vi.mocked(mockPortForwardManager.removeAllForwards).mockReset().mockResolvedValue(undefined) - vi.useRealTimers() - } - }) - it('returns as soon as a fast shutdown drains rather than waiting out the budget', async () => { vi.useFakeTimers() try { @@ -259,69 +49,6 @@ describe('SSH IPC handlers', () => { } }) - it('refuses the replacement session a paused connect would publish after shutdown began', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockConnectionManager.disconnectAll.mockResolvedValue(undefined) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - // Why here: the old session's lease flush is the one window where doConnect resumes straight into - // publishing its replacement session without re-checking authority. - let releaseDetach = (): void => {} - const detachFlush = new Promise((resolve) => { - releaseDetach = resolve - }) - let signalEnteredDetach = (): void => {} - const enteredDetach = new Promise((resolve) => { - signalEnteredDetach = resolve - }) - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce(() => { - signalEnteredDetach() - return detachFlush - }) - - const replacement = handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - // Why await the flush entry and not ticks: shutdown must not snapshot what to drain until the - // replacement connect is parked in the lease flush. - await enteredDetach - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - - mockConnectionManager.disconnectAll.mockClear() - // Why latch the gate here: the committed quit path owns it, so the drain alone must not fence. - quitTeardownStartGate.tryStart({ preventDefault() {} }) - const shutdown = beginSshShutdown() - releaseDetach() - - await expect(replacement).rejects.toThrow('closed for app shutdown') - await shutdown - - // Why one detach: the fence sits at the publication point, so the resumed connect never registers a - // replacement session — only the old session it had already torn down was detached. - const detaches = mockStore.markSshRemotePtyLeasesAsync.mock.calls.filter( - (call) => call[1] === 'detached' - ) - expect(detaches).toHaveLength(1) - // Why twice: once for the drain's snapshot, once after joining the connect that was still in flight. - expect(mockConnectionManager.disconnectAll).toHaveBeenCalledTimes(2) - expect(getActiveMultiplexer('ssh-1')).toBeUndefined() - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'closed for app shutdown' - ) - }) - it('joins an in-flight test-connection probe before the final shutdown disconnect', async () => { const target: SshTarget = { id: 'ssh-probe', diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index e626968ea062..d0a2d8ccf107 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,39 +1,7 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection-manager' -import type { SshExecOptions } from '../ssh/ssh-connection-utils' -import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' -import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' -import { sortDirEntries } from '../../shared/file-name-sort' -import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' +import { browseSshDirectory, type RemoteBrowseResult } from '../ssh/ssh-remote-directory-browse' -export type RemoteDirEntry = { - name: string - isDirectory: boolean -} - -type RemoteBrowseResult = { - entries: RemoteDirEntry[] - resolvedPath: string - pathFlavor: FilesystemPathFlavor -} - -const SSH_BROWSE_TIMEOUT_MS = 15_000 - -// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one. -const POSIX_COMMAND_NOT_FOUND_EXIT = 127 - -// Carries the raw exit code so the fallback can distinguish 127 (no powershell.exe → not Windows) from a genuine PowerShell error. -class RemoteBrowseError extends Error { - constructor( - message: string, - readonly exitCode: number | null - ) { - super(message) - this.name = 'RemoteBrowseError' - } -} - -// Why: relay fs.readDir needs workspace-root ACLs that don't exist until a repo is added, so browse over raw SSH exec. export function registerSshBrowseHandler( getConnectionManager: () => SshConnectionManager | null ): void { @@ -41,243 +9,7 @@ export function registerSshBrowseHandler( ipcMain.handle( 'ssh:browseDir', - async (_event, args: { targetId: string; dirPath: string }): Promise => { - const mgr = getConnectionManager() - if (!mgr) { - throw new Error('SSH connection manager not initialized') - } - const conn = mgr.getConnection(args.targetId) - if (!conn) { - throw new Error(`SSH connection "${args.targetId}" not found`) - } - - try { - return await browseWithPosixShell(conn, args.dirPath) - } catch (posixError) { - // Why: only a RemoteBrowseError (ran, non-zero exit) signals a Windows shell; don't retry transport errors/timeouts as Windows. - if (!(posixError instanceof RemoteBrowseError)) { - throw posixError - } - try { - return await browseWithWindowsPowerShell(conn, args.dirPath) - } catch (fallbackError) { - // Why: exit 127 (no powershell.exe) → host isn't Windows, surface the original POSIX failure; otherwise PowerShell's own error is the real cause. - throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError - } - } - } + (_event, args: { targetId: string; dirPath: string }): Promise => + browseSshDirectory(getConnectionManager(), args.targetId, args.dirPath) ) } - -type SshBrowseConnection = NonNullable> - -function browseWithPosixShell( - conn: SshBrowseConnection, - dirPath: string -): Promise { - // Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs. - return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix') -} - -function browseWithWindowsPowerShell( - conn: SshBrowseConnection, - dirPath: string -): Promise { - if (/^[\\/]+$/.test(dirPath.trim())) { - const script = [ - "$ErrorActionPreference = 'Stop'", - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - "Write-Output '/'", - "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }" - ].join('; ') - return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) - } - - const script = [ - "$ErrorActionPreference = 'Stop'", - // Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake. - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - `$dir = ${powerShellPathExpression(dirPath)}`, - 'Set-Location -LiteralPath $dir', - '$resolved = (Get-Location).ProviderPath', - // Why: the renderer's parentPath/joinPath split only on `/`, so emit a forward-slash resolvedPath while keeping native $resolved for Get-ChildItem. - "Write-Output ($resolved -replace '\\\\', '/')", - 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', - " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", - '}' - ].join('; ') - - return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) -} - -async function runBrowseCommand( - conn: SshBrowseConnection, - command: string, - pathFlavor: FilesystemPathFlavor, - options?: SshExecOptions -): Promise { - // Why: login-shell rc output would otherwise be read as the resolved path. - const fence = options?.wrapCommand === false ? null : buildPosixStdoutFence(command, 'SSH') - const remoteCommand = fence?.command ?? command - const channel = options ? await conn.exec(remoteCommand, options) : await conn.exec(remoteCommand) - - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let exitCode: number | null = null - let settled = false - let timeout: ReturnType | null = null - - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('exit', onExit) - channel.off('close', onClose) - channel.off('error', onError) - channel.stderr.off('error', onError) - } - const rejectOnce = (error: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - const closeChannel = (): void => { - const closable = channel as { close?: () => void; destroy?: () => void } - try { - if (typeof closable.close === 'function') { - closable.close() - } else if (typeof closable.destroy === 'function') { - closable.destroy() - } - } catch { - /* best effort */ - } - } - const onTimeout = (): void => { - // Why: no relay deadline exists during add-project browsing, so bound this raw exec channel or Add Remote Project hangs forever. - rejectOnce(new Error('Remote directory listing timed out')) - closeChannel() - } - const resolveOnce = (result: RemoteBrowseResult): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(result) - } - - const onStdoutData = (data: Buffer): void => { - stdout += data.toString() - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString() - } - // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. - const onExit = (code: number | null): void => { - exitCode = code - } - const onError = (error: Error): void => { - rejectOnce(error) - } - const onClose = (): void => { - stdout = fence?.readStdout(stdout) ?? stdout - // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. - if (exitCode !== 0) { - const msg = - stderr.trim() || - (exitCode === null - ? 'Remote listing failed (channel closed without exit status)' - : `Remote listing failed (exit ${exitCode})`) - rejectOnce(new RemoteBrowseError(msg, exitCode)) - return - } - if (stderr.trim() && !stdout.trim()) { - rejectOnce(new Error(stderr.trim())) - return - } - - // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. - const lines = stdout.trim().split(/\r?\n/) - if (lines.length === 0) { - rejectOnce(new Error('Empty response from remote')) - return - } - - const resolvedPath = lines[0] - const entries: RemoteDirEntry[] = [] - - for (let i = 1; i < lines.length; i++) { - const line = lines[i] - if (!line || line === './' || line === '../') { - continue - } - if (line.endsWith('/')) { - entries.push({ name: line.slice(0, -1), isDirectory: true }) - } else { - entries.push({ name: line, isDirectory: false }) - } - } - - // Sort: directories first, then natural name order (matches the Explorer) - sortDirEntries(entries) - - resolveOnce({ entries, resolvedPath, pathFlavor }) - } - - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('exit', onExit) - channel.on('close', onClose) - // Why: SSH exec streams emit `error` on transport loss; without a scoped listener a disappearing remote can become process-fatal. - channel.on('error', onError) - channel.stderr.on('error', onError) - timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} - -// Why: exit 127 means powershell.exe wasn't found — the host isn't Windows, so surface the original POSIX failure instead. -function isPosixCommandNotFound(error: unknown): boolean { - return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT -} - -// Why: single-quote to block shell injection; ~ needs $HOME since single quotes suppress tilde expansion. -function shellEscape(s: string): string { - if (s === '~') { - return '"$HOME"' - } - if (s.startsWith('~/')) { - return `"$HOME"/${shellEscapeRaw(s.slice(2))}` - } - return shellEscapeRaw(s) -} - -function shellEscapeRaw(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'` -} - -function powerShellPathExpression(s: string): string { - if (s === '~') { - return '$HOME' - } - if (s.startsWith('~/') || s.startsWith('~\\')) { - return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` - } - return powerShellLiteral(normalizeWindowsDrivePath(s)) -} - -// Why: renderer's POSIX path rebuild yields '/C:/…' or bare 'C:' (drive-relative), both mis-resolved by Set-Location; re-root to a proper drive path. -function normalizeWindowsDrivePath(s: string): string { - const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') - return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped -} diff --git a/src/main/ipc/ssh-connect-attempt-registry.ts b/src/main/ipc/ssh-connect-attempt-registry.ts index 5aba526d6832..c1986886b1e9 100644 --- a/src/main/ipc/ssh-connect-attempt-registry.ts +++ b/src/main/ipc/ssh-connect-attempt-registry.ts @@ -16,7 +16,6 @@ export type ConnectAttempt = { } export const connectInFlight = new Map() -export const pendingTransportReconnects = new Set() // Why the quit gate rather than a local latch: "the committed quit has begun" already has an owner, // and a private copy could be set by something that is not actually quitting — leaving SSH connects @@ -29,7 +28,6 @@ export function assertSshConnectsNotFenced(): void { export function invalidateConnectAttempt(targetId: string): void { rotateSshProviderAuthority(targetId) - pendingTransportReconnects.delete(targetId) connectInFlight.delete(targetId) credentialRequestedForTarget.delete(targetId) } @@ -38,9 +36,6 @@ export function isCurrentConnectAttempt(targetId: string, authority: DirectSshAu return authority.targetId === targetId && isCurrentSshProviderAuthority(authority) } -// Why: publish reset's teardown/force-stop/disconnect lifecycle so new connects and duplicate resets can't race it. -export const resetRelayInFlight = new Map>() - // Why: ssh:testConnection connects then disconnects; suppressing broadcasts during the test avoids worktree cards flashing connected → disconnected. export const testingTargets = new Set() export const testConnectionProbes = new Set>() diff --git a/src/main/ipc/ssh-connect-recheck-cancellation.test.ts b/src/main/ipc/ssh-connect-decision-cancellation.test.ts similarity index 61% rename from src/main/ipc/ssh-connect-recheck-cancellation.test.ts rename to src/main/ipc/ssh-connect-decision-cancellation.test.ts index e55a00cdf89e..322821026025 100644 --- a/src/main/ipc/ssh-connect-recheck-cancellation.test.ts +++ b/src/main/ipc/ssh-connect-decision-cancellation.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -26,48 +24,17 @@ vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) import type { SshTarget } from '../../shared/ssh-types' import type { SshConnection } from '../ssh/ssh-connection' import { recordSshConnectionOpened } from '../ssh/ssh-connection-attribution' -import { - decideHostServer, - publishHostServerDecisionFailure, - refineRelayTerminalDecision -} from './ssh-host-server-connect' +import { decideHostServer, publishHostServerDecisionFailure } from './ssh-host-server-connect' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks +const { mockSshStore, mockConnectionManager } = mocks -describe('a connect cancelled during the relay-terminal re-check', () => { +describe('a connect cancelled during its server decision', () => { const harness = createSshIpcHarness(mocks) - const { handlers, createRelayLaunchResult } = harness + const { handlers } = harness beforeEach(harness.reset) - it('never reports connected for a connect cancelled during the relay-terminal re-check', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = { id: 'rechecking-transport' } - let releaseRecheck = (): void => {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockDeployAndLaunchRelay.mockResolvedValueOnce(createRelayLaunchResult()) - vi.mocked(refineRelayTerminalDecision).mockImplementationOnce( - () => new Promise((resolve) => (releaseRecheck = resolve)) - ) - const connect = Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) - await vi.waitFor(() => expect(refineRelayTerminalDecision).toHaveBeenCalled()) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - releaseRecheck() - - await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', conn) - }) - it('publishes the error when a managed host fails to set up, rather than staying connecting', async () => { const target: SshTarget = { id: 'ssh-1', @@ -83,30 +50,6 @@ describe('a connect cancelled during the relay-terminal re-check', () => { expect(publishHostServerDecisionFailure).toHaveBeenCalledWith('ssh-1', failure) }) - it('publishes the setup failure when a failed setup kept the host fenced', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - orcadFence: { environmentId: 'env-1' } - } - mockSshStore.getTarget.mockReturnValue(target) - vi.mocked(decideHostServer).mockResolvedValueOnce({ - route: 'relay', - reason: 'failed', - detail: 'The destination could not stage the migration.' - }) - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'The destination could not stage the migration.' - ) - expect(publishHostServerDecisionFailure).toHaveBeenCalledWith( - 'ssh-1', - expect.objectContaining({ message: 'The destination could not stage the migration.' }) - ) - }) - it('closes a transport the cancelled decision opened after the user disconnected', async () => { const target: SshTarget = { id: 'ssh-1', @@ -126,7 +69,7 @@ describe('a connect cancelled during the relay-terminal re-check', () => { // The census dials after the teardown, opening a transport no one else holds. recordSshConnectionOpened(asTransport(opened)) mockConnectionManager.getConnection.mockReturnValue(opened) - return null + return { route: 'relay', reason: 'failed' } }) await expect( Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) @@ -146,8 +89,6 @@ describe('a connect cancelled during the relay-terminal re-check', () => { mockSshStore.getTarget.mockReturnValue(target) mockConnectionManager.getConnection.mockReturnValue(undefined) mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockConnectionManager.connect.mockResolvedValue(opened) - mockDeployAndLaunchRelay.mockResolvedValue(createRelayLaunchResult()) let resumeStale = (): void => {} let staleDecided = false vi.mocked(decideHostServer).mockImplementationOnce(async () => { @@ -156,12 +97,12 @@ describe('a connect cancelled during the relay-terminal re-check', () => { recordSshConnectionOpened(asTransport(opened)) mockConnectionManager.getConnection.mockReturnValue(opened) await new Promise((resolve) => (resumeStale = resolve)) - return null + return { route: 'relay', reason: 'orcad_unavailable', detail: 'unsupported_host' } }) const stale = Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) await vi.waitFor(() => expect(staleDecided).toBe(true)) await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - // The replacement reuses the pooled transport and completes, leaving connectInFlight. + // The managed replacement adopts the pooled transport and completes. await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) mockConnectionManager.disconnectConnection.mockClear() resumeStale() @@ -193,30 +134,6 @@ describe('a connect cancelled during the relay-terminal re-check', () => { ).rejects.toBe(failure) expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened) }) - - it('closes the transport a fenced failed setup dialed before reporting it', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - orcadFence: { environmentId: 'env-1' } - } - const opened = { id: 'conversion-transport' } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - vi.mocked(decideHostServer).mockImplementationOnce(async () => { - await Promise.resolve() - recordSshConnectionOpened(asTransport(opened)) - mockConnectionManager.getConnection.mockReturnValue(opened) - return { route: 'relay', reason: 'failed', detail: 'Staging failed.' } - }) - await expect( - Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) - ).rejects.toThrow('Staging failed.') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened) - }) }) /** The stand-in transports these tests hand the mocked pool. */ diff --git a/src/main/ipc/ssh-connect-flow.ts b/src/main/ipc/ssh-connect-flow.ts index dc11e5121565..5c3b82a1e36b 100644 --- a/src/main/ipc/ssh-connect-flow.ts +++ b/src/main/ipc/ssh-connect-flow.ts @@ -1,61 +1,28 @@ import { appendFileSync } from 'node:fs' -import type { SshConnection } from '../ssh/ssh-connection' -import { SshRelaySession } from '../ssh/ssh-relay-session' -import type { SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types' +import type { SshConnectionState } from '../../shared/ssh-types' import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation' -import { isAuthError } from '../ssh/ssh-connection-utils' import { getSshProviderAuthority, isCurrentSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { allowsDirectSshRelay } from '../ssh/ssh-connection-store' import { adoptSshConnection, runAttributedToSshOwner } from '../ssh/ssh-connection-attribution' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { decideHostServer, recheckWhenManagedFenceClears, publishHostServerDecisionFailure, - refineRelayTerminalDecision, publishManagedServerConnect, - recordRelayDecision + publishUnservedHostServer } from './ssh-host-server-connect' -import { activeSessions } from './ssh-active-relay-sessions' import { assertSshConnectsNotFenced, connectInFlight, - credentialRequestedForTarget, - isCurrentConnectAttempt, - pendingTransportReconnects, - resetRelayInFlight + isCurrentConnectAttempt } from './ssh-connect-attempt-registry' -import { - handleSshConnectionStateChange, - relayGracePeriodForTarget -} from './ssh-connection-state-callbacks' -import { - connectionManager, - currentRuntime, - getCurrentMainWindow, - persistedStore, - portForwardManager -} from './ssh-ipc-context' -import { clearRelayLostBackoff, relayLostBackoff } from './ssh-relay-lost-backoff' -import { configureRelaySessionCallbacks } from './ssh-relay-session-callbacks' -import { - broadcastDetectedPortsFromCurrentWindow, - broadcastSshState, - clearRelayStateOverride, - getPublicSshState, - relayStateOverrides -} from './ssh-renderer-broadcast' -import { - abandonCancelledConnectAttempt, - abandonDecisionTransport, - abandonFailedSshSession -} from './ssh-session-teardown' +import { connectionManager } from './ssh-ipc-context' +import { abandonDecisionTransport } from './ssh-session-teardown' import { awaitTargetLifecycle } from './ssh-target-lifecycle-queue' -import { errorMessage } from '../../shared/error-message' export async function connectTarget(targetId: string): Promise { const e2eProbePath = process.env.ORCA_E2E_FORBID_LOCAL_SSH_CONNECT_PROBE @@ -66,10 +33,6 @@ export async function connectTarget(targetId: string): Promise ({ ok: true }) as const, - (error: unknown) => ({ ok: false, error }) as const - ) - : null - let conn - // Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error). - if (existingSession) { - // Why: await port teardown before disposing, else the new session's restorePortForwards can hit EADDRINUSE on not-yet-released ports. - await portForwardManager!.removeAllForwards(targetId) - if (!isCurrentConnectAttempt(targetId, authority)) { - throw createCancelledConnectAttemptError() - } - try { - await existingSession.detachAndPersist() - } finally { - // Why finally: detachAndPersist runs its in-memory half synchronously, so the session is - // dead even when the lease write rejects — keeping it in activeSessions would strand every - // later connect on the same dead session. Why still after the await, not before it: the - // write has settled by now, so it can no longer clobber the replacement's 'attached' write. - if (activeSessions.get(targetId) === existingSession) { - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) - } - } - } - - if (pendingTransportDisconnect) { - const disconnectResult = await pendingTransportDisconnect - if (!disconnectResult.ok) { - throw disconnectResult.error - } + if (replacePendingTransport) { + await connectionManager!.disconnect(targetId) if (!isCurrentConnectAttempt(targetId, authority)) { throw createCancelledConnectAttemptError() } } - // Why before the decision: a transport the relay connect below reuses was not this attempt's. - const priorConnection = connectionManager!.getConnection(targetId) // A transport the decision's census, deploy or conversion opens is attributed to this attempt, // so a cancelled attempt closes exactly that one and nothing a newer owner took over. const owner = Symbol(targetId) @@ -205,7 +112,7 @@ async function doConnect( throw createCancelledConnectAttemptError() } adoptCurrentTransport(targetId, owner) - if (server?.route === 'managed') { + if (server.route === 'managed') { if (server.fenceHeld) { recheckWhenManagedFenceClears(target, server.environmentId) } @@ -216,125 +123,9 @@ async function doConnect( server.serving ) } - if (server) { - recordRelayDecision(target, server) - } - // Re-read: a conversion attempt may have fenced the host since the lookup above. - const relayTarget = getSshTargetRegistryStore()!.getTarget(targetId) ?? target - if (!allowsDirectSshRelay(relayTarget)) { - // A setup that failed but kept its fence: the relay decision's detail is the real cause. - const blocked = new Error( - server?.detail ?? - 'This SSH host serves a managed Orca server; it is reached through that server.' - ) - await abandonDecisionTransport(targetId, owner, authority) - publishHostServerDecisionFailure(targetId, blocked) - throw blocked - } - - // Why here and not only at entry: this is the publication point, and it is the last statement - // before the transport opens. Checking it in the same synchronous block as activeSessions.set - // means a connect either registers before the shutdown drain snapshots, or registers never and - // owns nothing to clean up. - assertSshConnectsNotFenced() - // Why: create the session early so onStateChange sees it in 'deploying' and skips reconnect logic. - const session = new SshRelaySession( - targetId, - getCurrentMainWindow, - persistedStore!, - portForwardManager!, - currentRuntime, - broadcastDetectedPortsFromCurrentWindow - ) - configureRelaySessionCallbacks(session) - activeSessions.set(targetId, session) - const ownsSession = (): boolean => - isCurrentConnectAttempt(targetId, authority) && activeSessions.get(targetId) === session - - const mintedConnection = (): SshConnection | null => - conn && conn !== priorConnection ? conn : null - - try { - conn = await connectionManager!.connect(target) - adoptSshConnection(conn, owner) - if (!ownsSession()) { - throw createCancelledConnectAttemptError() - } - } catch (err) { - // Why: connect()'s internal state may not have reached the renderer; broadcast explicitly so the UI leaves 'connecting'. - const errObj = err instanceof Error ? err : new Error(String(err)) - const status: SshConnectionStatus = isAuthError(errObj) ? 'auth-failed' : 'error' - if (!ownsSession()) { - await abandonCancelledConnectAttempt(targetId, session, mintedConnection()) - throw createCancelledConnectAttemptError() - } - // Why: clear this failed connect's flag so a later non-prompting connect isn't deferred. - credentialRequestedForTarget.delete(targetId) - await abandonFailedSshSession(targetId, session) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) - broadcastSshState(getCurrentMainWindow, targetId, { - targetId, - status, - error: errObj.message, - reconnectAttempt: 0 - }) - throw err - } - - try { - handleSshConnectionStateChange(targetId, { - targetId, - status: 'deploying-relay', - error: null, - reconnectAttempt: 0 - }) - - await session.establish(conn, relayGracePeriodForTarget(target)) - if (!ownsSession()) { - throw createCancelledConnectAttemptError() - } - await refineRelayTerminalDecision(target, server, ownsSession) - // The re-check can wait seconds on the relay; a connect cancelled meanwhile must not report. - if (!ownsSession()) { - throw createCancelledConnectAttemptError() - } - - // Why: we manually pushed `deploying-relay`, so send `connected` straight to the renderer — routing through onStateChange would trigger reconnect logic. - clearRelayStateOverride(targetId) - broadcastSshState(getCurrentMainWindow, targetId, { - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - supportsFolderDownload: conn.usesSystemSshTransport?.() !== true - }) - } catch (err) { - if (!ownsSession()) { - await abandonCancelledConnectAttempt(targetId, session, mintedConnection()) - throw createCancelledConnectAttemptError() - } - await abandonFailedSshSession(targetId, session) - clearRelayLostBackoff(targetId) - try { - await connectionManager!.disconnect(targetId) - } catch (disconnectError) { - // Why: the establish failure is the actionable error; a teardown throw must not replace it. - console.warn( - `[ssh] Failed to disconnect transport after failed establish for ${targetId}: ${errorMessage(disconnectError)}` - ) - } - throw err - } - - // Why: persist whether this connect needed a credential so startup can partition targets into eager vs deferred without re-probing keys. - const requiredPassphrase = credentialRequestedForTarget.has(targetId) - credentialRequestedForTarget.delete(targetId) - getSshTargetRegistryStore()!.updateTarget(targetId, { - lastRequiredPassphrase: requiredPassphrase - }) - - return getPublicSshState(targetId)! + // This version has no relay to fall back to: a host its managed server doesn't serve fails here. + await abandonDecisionTransport(targetId, owner, authority) + throw publishUnservedHostServer(target, server) } function adoptCurrentTransport(targetId: string, owner: symbol): void { diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 3542b21f7e82..dce8bf94c761 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -1,94 +1,16 @@ import { ipcMain } from 'electron' -import { isLiveSshPtyLease, type SshTarget } from '../../shared/ssh-types' -import { toAppSshPtyId } from '../providers/ssh-pty-id' -import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { setSshTargetRegistryHandlers, getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection } from './pty' -import { activeSessions } from './ssh-active-relay-sessions' import { assertSshConnectsNotFenced, connectInFlight, - credentialRequestedForTarget, - resetRelayInFlight, runSshTestConnectionProbe } from './ssh-connect-attempt-registry' import { connectTarget } from './ssh-connect-flow' -import { connectionManager, persistedStore } from './ssh-ipc-context' +import { connectionManager } from './ssh-ipc-context' import { getPublicSshState } from './ssh-renderer-broadcast' -import { disconnectRegisteredSshTarget, teardownActiveSshSession } from './ssh-session-teardown' -import { terminateSshTargetSessions } from './ssh-terminate-sessions' -import { assertNotManagedServerHost } from './ssh-target-crud-handlers' -import { moveSshHostToManagedServer } from './ssh-managed-server-move' -import { runTargetLifecycle } from './ssh-target-lifecycle-queue' +import { disconnectRegisteredSshTarget } from './ssh-session-teardown' import { errorMessage } from '../../shared/error-message' -async function doResetRelay(targetId: string, target: SshTarget): Promise { - const inFlightConnect = connectInFlight.get(targetId) - if (inFlightConnect) { - try { - // Why: resetting activeSessions mid-deploy would dispose the session doConnect will use. - await inFlightConnect.promise - } catch { - // The reset can still recover a stale remote relay after a failed connect. - } - } - - rotateSshProviderAuthority(targetId) - const session = activeSessions.get(targetId) - if (session) { - // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. - await teardownActiveSshSession(targetId, (capturedSession) => - capturedSession.detachAndPersist() - ) - } - - const existingConn = connectionManager!.getConnection(targetId) - let conn = existingConn - if (!conn) { - // Why re-check: admission fenced this reset before it parked on the in-flight connect, so shutdown - // may have started (and drained) while we waited — opening a transport now would outlive the drain. - assertSshConnectsNotFenced() - conn = await connectionManager!.connect(target) - } - let relayStopAcknowledged = false - try { - await forceStopRelayForTarget(conn, targetId) - relayStopAcknowledged = true - } finally { - const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { - // Deliberately the raw state, not `sshRemotePtyLeaseAllowsReattach`: this asks which routes - // the force-stop just invalidated, not which leases may be reattached. An already-`expired` - // lease has no route left for reset to retire — re-marking it `expired` is a no-op write, and - // any local handle this connection really holds arrives through `getPtyIdsForConnection` - // above, whatever the lease says. Reset also may not upgrade it to `terminated`: killing the - // relay daemon makes its PTYs permanently unreachable, which is not evidence they exited - // (docs/reference/ssh-execution-boundary.md). Nothing here can adopt a stranger either — the - // replacement relay namespaces every id under a fresh mint epoch, so an old orphan lease can - // only fail its next reattach. - if (isLiveSshPtyLease(lease)) { - ptyIds.add(lease.ptyId) - // Why: only a host-acknowledged force-stop may retire a lease. When it threw we never - // observed those shells, so expiring them would record a verdict we do not hold; mirrors - // ssh:terminateSessions, and the next connect re-attaches (or expires) them on evidence. - if (relayStopAcknowledged) { - persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') - } - } - } - // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. - for (const ptyId of ptyIds) { - const appPtyId = toAppSshPtyId(targetId, ptyId) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - } - // Why: reset's connect() may trip onCredentialRequest; clear so a later non-prompting doConnect doesn't persist lastRequiredPassphrase=true. - credentialRequestedForTarget.delete(targetId) - await connectionManager!.disconnect(targetId) - } -} - export function registerSshConnectionHandlers(): void { setSshTargetRegistryHandlers({ connect: connectTarget, @@ -103,46 +25,6 @@ export function registerSshConnectionHandlers(): void { await disconnectRegisteredSshTarget(args.targetId) }) - ipcMain.handle( - 'ssh:terminateSessions', - (_event, args: { targetId: string; forRemoval?: boolean }) => { - // Why here: only main sees a fence that landed after the renderer loaded its targets. - if (args.forRemoval) { - assertNotManagedServerHost(args.targetId) - } - return terminateSshTargetSessions(args.targetId) - } - ) - - ipcMain.handle('ssh:moveToManagedServer', (_event, args: { targetId: string }) => - moveSshHostToManagedServer(args.targetId) - ) - - ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => { - const existingReset = resetRelayInFlight.get(args.targetId) - if (existingReset) { - return existingReset - } - - const target = getSshTargetRegistryStore()!.getTarget(args.targetId) - if (!target) { - throw new Error(`SSH target "${args.targetId}" not found`) - } - // Why: reset opens its own transport, so it must be fenced by shutdown the same way connect is. - assertSshConnectsNotFenced() - - let resetPromise: Promise - resetPromise = runTargetLifecycle(args.targetId, () => - doResetRelay(args.targetId, target) - ).finally(() => { - if (resetRelayInFlight.get(args.targetId) === resetPromise) { - resetRelayInFlight.delete(args.targetId) - } - }) - resetRelayInFlight.set(args.targetId, resetPromise) - return resetPromise - }) - ipcMain.handle('ssh:getState', (_event, args: { targetId: string }) => { return getPublicSshState(args.targetId) }) @@ -163,18 +45,7 @@ export function registerSshConnectionHandlers(): void { throw new Error(`SSH target "${args.targetId}" not found`) } - // Why: with a live/reconnecting session, testConnection's disconnect() would tear down the relay stack (PTYs, watchers), so skip. - const existingSession = activeSessions.get(args.targetId) - const sessionState = existingSession?.getState() - if ( - sessionState === 'ready' || - sessionState === 'deploying' || - sessionState === 'reconnecting' - ) { - return { success: true, state: connectionManager!.getState(args.targetId) } - } - - // Why: testConnection's disconnect() would tear down an in-flight connect's relay deployment; await it instead. + // Why: testConnection's disconnect() would tear down an in-flight connect's transport; await it instead. const inFlight = connectInFlight.get(args.targetId) if (inFlight) { try { diff --git a/src/main/ipc/ssh-connection-state-callbacks.ts b/src/main/ipc/ssh-connection-state-callbacks.ts index 51c68b948323..63c4f808d0dc 100644 --- a/src/main/ipc/ssh-connection-state-callbacks.ts +++ b/src/main/ipc/ssh-connection-state-callbacks.ts @@ -1,122 +1,38 @@ import type { SshConnectionCallbacks } from '../ssh/ssh-connection' -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import type { SshConnectionState } from '../../shared/ssh-types' import { isSshHostCensusInFlight } from '../ssh/ssh-connection-attribution' -import { activeSessions } from './ssh-active-relay-sessions' import { connectInFlight, credentialRequestedForTarget, - pendingTransportReconnects, testingTargets } from './ssh-connect-attempt-registry' -import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' +import { getCurrentMainWindow } from './ssh-ipc-context' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' import { requestCredential } from './ssh-passphrase' -import { clearRelayLostBackoff } from './ssh-relay-lost-backoff' -import { - broadcastSshState, - clearRelayStateOverride, - publishRelayOverride, - relayStateOverrides -} from './ssh-renderer-broadcast' - -export function relayGracePeriodForTarget( - target: SshTarget | null | undefined -): number | undefined { - return target?.relayGracePeriodSeconds -} +import { broadcastSshState } from './ssh-renderer-broadcast' -// Why extracted from the callbacks object: an explicit connect pushes its own 'deploying-relay' -// through this exact path, so both callers share one implementation. export function handleSshConnectionStateChange(targetId: string, state: SshConnectionState): void { if (testingTargets.has(targetId)) { return } - - // Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'. - const session = activeSessions.get(targetId) - const sessionState = session?.getState() - const transportReconnectStarted = - state.status === 'reconnecting' && - (sessionState === 'ready' || sessionState === 'reconnecting') && - !pendingTransportReconnects.has(targetId) - if (transportReconnectStarted) { - rotateSshProviderAuthority(targetId) - pendingTransportReconnects.add(targetId) - } else if ( - state.status === 'disconnected' || - state.status === 'auth-failed' || - state.status === 'reconnection-failed' || - state.status === 'error' - ) { - pendingTransportReconnects.delete(targetId) - } - const completedTransportReconnect = - state.status === 'connected' && pendingTransportReconnects.delete(targetId) - const shouldReconnectRelay = - session !== undefined && - completedTransportReconnect && - state.reconnectAttempt === 0 && - (sessionState === 'ready' || sessionState === 'reconnecting') - const relayReconnectAlreadyInFlight = - !completedTransportReconnect && + if ( state.status === 'connected' && - sessionState === 'reconnecting' && - relayStateOverrides.has(targetId) - - if (shouldReconnectRelay) { - // Why: this branch redeploys the relay itself over a fresh transport, so any pending relay-lost retry is stale — dropping it also gives the new transport generation a full attempt budget. - clearRelayLostBackoff(targetId) - // Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again. - publishRelayOverride( - getCurrentMainWindow, - targetId, - 'reconnecting', - 'Relay channel reconnecting...', - state.reconnectAttempt - ) - } else if (relayReconnectAlreadyInFlight) { - // Why: duplicate connected notifications belong to the same socket generation and must not expose providers before relay recovery finishes. - return - } else if ( - state.status === 'connected' && - // No session yet: the server decision's census, deploy or conversion opened the transport. - sessionState !== 'ready' && - !completedTransportReconnect && (connectInFlight.has(targetId) || isSshHostCensusInFlight(targetId)) ) { - // Why: the raw SSH transport reaches 'connected' before the relay session establishes during an - // explicit connect. Forwarding it makes the renderer treat the host as fully up — it remounts - // SSH panes (-> window.api.ssh.connect) and fires connected-gated data reads before any provider - // exists. On a permanent relay-deploy failure that premature 'connected' drives an unbounded - // reconnect loop. Hold it at 'deploying-relay'; the in-flight doConnect broadcasts the - // authoritative 'connected' directly (bypassing this callback) after establish() succeeds, or a - // terminal state on failure. The connectInFlight gate keeps this scoped to a live connect, so a - // stray raw 'connected' with no follow-up (e.g. a transport blip on a session left 'idle' by a - // relay version mismatch) is never wedged at 'deploying-relay'. - clearRelayStateOverride(targetId) + // Why: the raw transport reaches 'connected' while the connect is still deciding the host's + // server; forwarding it would let the renderer treat the host as up before anything serves it. broadcastSshState(getCurrentMainWindow, targetId, { targetId, - // Before any session the connect is still deciding the host's server. - status: session ? 'deploying-relay' : 'connecting', + status: 'connecting', error: state.error, reconnectAttempt: state.reconnectAttempt }) - } else { - clearRelayStateOverride(targetId) - broadcastSshState(getCurrentMainWindow, targetId, state) - } - - if (!session) { return } - // Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session. - if (shouldReconnectRelay) { - const target = getSshTargetRegistryStore()?.getTarget(targetId) - const conn = connectionManager?.getConnection(targetId) - if (conn) { - void session.reconnect(conn, relayGracePeriodForTarget(target)) - } + broadcastSshState(getCurrentMainWindow, targetId, state) + // An explicit connect restores through its own publish; this covers a transport that recovered. + if (state.status === 'connected') { + void restoreManagedHostPortForwards(targetId) } } diff --git a/src/main/ipc/ssh-disconnect-cancellation.test.ts b/src/main/ipc/ssh-disconnect-cancellation.test.ts index 5448580c7ed0..5abefd2cce99 100644 --- a/src/main/ipc/ssh-disconnect-cancellation.test.ts +++ b/src/main/ipc/ssh-disconnect-cancellation.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,21 +21,14 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { getActiveMultiplexer } from './ssh' import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { - mockSshStore, - mockConnectionManager, - mockDeployAndLaunchRelay, - mockMux, - mockPortForwardManager -} = mocks +const { mockSshStore, mockConnectionManager } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { handlers, mockStore, createRelayLaunchResult } = harness + const { handlers } = harness beforeEach(harness.reset) @@ -72,373 +63,4 @@ describe('SSH IPC handlers', () => { await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') expect(mockConnectionManager.connect).not.toHaveBeenCalled() }) - - it('invalidates a pending connect when disconnect wins and allows a fresh connect', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const staleConn = {} - const freshConn = {} - let resolveStaleConnect!: (connection: unknown) => void - let resolveForwardRemoval!: () => void - let transportConnectPending = false - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect - .mockReturnValueOnce( - new Promise((resolve) => { - transportConnectPending = true - resolveStaleConnect = resolve - }) - ) - .mockImplementationOnce(async () => { - if (transportConnectPending) { - throw new Error('Connection to Server is already in progress') - } - return freshConn - }) - mockConnectionManager.disconnect.mockImplementationOnce(async () => { - transportConnectPending = false - }) - mockPortForwardManager.removeAllForwards.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveForwardRemoval = resolve - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - const disconnect = handlers.get('ssh:disconnect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) - const freshConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - resolveStaleConnect(staleConn) - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveForwardRemoval() - await disconnect - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - - await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - }) - - it('closes the transport a cancelled connect opened after the disconnect finished', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const lateConn = { id: 'late-transport' } - let resolveStaleConnect!: (connection: unknown) => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.connect.mockReturnValueOnce( - new Promise((resolve) => { - resolveStaleConnect = resolve - }) - ) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - // Why await the whole disconnect: the leak only exists once its teardown has already run, so - // nothing else is left to close the transport this attempt opens afterwards. - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - - resolveStaleConnect(lateConn) - - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', lateConn) - expect(getActiveMultiplexer('ssh-1')).toBeUndefined() - }) - - it('closes the transport when establish resumes after the connect was invalidated', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = { id: 'establishing-transport' } - let releaseRelayLaunch = (): void => {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockDeployAndLaunchRelay.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRelayLaunch = () => resolve(createRelayLaunchResult()) - }) - ) - - const connect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1)) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - - releaseRelayLaunch() - - await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', conn) - expect(getActiveMultiplexer('ssh-1')).toBeUndefined() - }) - - it('leaves a reused transport to its replacement when the connect is cancelled', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - // Why: connect() hands back the already-open transport, so this attempt never owned it. - const sharedConn = { id: 'shared-transport' } - let resolveStaleConnect!: (connection: unknown) => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(sharedConn) - mockConnectionManager.connect.mockReturnValueOnce( - new Promise((resolve) => { - resolveStaleConnect = resolve - }) - ) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - - resolveStaleConnect(sharedConn) - - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).not.toHaveBeenCalled() - }) - - it('keeps reconnect behind transport disconnect when forward teardown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveTransportDisconnect!: () => void - let transportDisconnectPending = false - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValueOnce({}).mockImplementationOnce(async () => { - if (transportDisconnectPending) { - throw new Error('Connection to Server is already in progress') - } - return {} - }) - mockConnectionManager.disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - transportDisconnectPending = true - resolveTransportDisconnect = () => { - transportDisconnectPending = false - resolve() - } - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( - new Error('forward teardown failed') - ) - - const disconnect = handlers.get('ssh:disconnect')!(null, { - targetId: 'ssh-1' - }) as Promise - const disconnectSettled = vi.fn() - void disconnect.then(disconnectSettled, disconnectSettled) - await vi.waitFor(() => - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - ) - const reconnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - const reconnectResult = reconnect.then( - (state) => ({ ok: true as const, state }), - (error: unknown) => ({ ok: false as const, error }) - ) - await Promise.resolve() - - expect(disconnectSettled).not.toHaveBeenCalled() - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveTransportDisconnect() - - await expect(disconnect).rejects.toThrow('forward teardown failed') - await expect(reconnectResult).resolves.toMatchObject({ - ok: true, - state: { targetId: 'ssh-1', status: 'connected' } - }) - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2) - expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') - }) - - it('retires a removed target session after forward teardown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveTransportDisconnect!: () => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveTransportDisconnect = resolve - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( - new Error('forward teardown failed') - ) - - const removal = handlers.get('ssh:removeTarget')!(null, { - id: 'ssh-1' - }) as Promise - await vi.waitFor(() => - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - ) - await Promise.resolve() - - expect(mockSshStore.removeTarget).not.toHaveBeenCalled() - resolveTransportDisconnect() - await removal - - expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') - expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') - expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') - }) - - it('replaces a stale shared connect after authority rotates without disconnect', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveStaleConnect!: (connection: unknown) => void - let resolveForwardRemoval!: () => void - let resolveTransportDisconnect!: () => void - let transportConnectPending = false - mockSshStore.getTarget.mockReturnValue(target) - mockSshStore.addTarget.mockReturnValue(target) - mockConnectionManager.connect - .mockReturnValueOnce( - new Promise((resolve) => { - transportConnectPending = true - resolveStaleConnect = resolve - }) - ) - .mockImplementationOnce(async () => { - if (transportConnectPending) { - throw new Error('Connection to Server is already in progress') - } - return {} - }) - mockConnectionManager.disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveTransportDisconnect = () => { - transportConnectPending = false - resolve() - } - }) - ) - mockPortForwardManager.removeAllForwards.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveForwardRemoval = resolve - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - const sharedStaleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - mockSshStore.lastRepoReadoptions = [ - { oldTargetId: 'ssh-1', newTargetId: 'ssh-new', repoIds: ['repo-1'] } - ] - await handlers.get('ssh:addTarget')!(null, { target }) - const freshConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - ) - await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) - const sharedFreshConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveForwardRemoval() - resolveTransportDisconnect() - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - - resolveStaleConnect({}) - - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - await expect(sharedStaleConnect).rejects.toThrow('SSH connection attempt was cancelled') - await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) - await expect(sharedFreshConnect).resolves.toMatchObject({ - targetId: 'ssh-1', - status: 'connected' - }) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/main/ipc/ssh-handler-reregistration.test.ts b/src/main/ipc/ssh-handler-reregistration.test.ts index 52c5ca366b20..ebcf35836cc1 100644 --- a/src/main/ipc/ssh-handler-reregistration.test.ts +++ b/src/main/ipc/ssh-handler-reregistration.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,20 +21,14 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { getSshConnectionManager, registerSshHandlers } from './ssh' +import { registerSshHandlers } from './ssh' import type { SshTarget } from '../../shared/ssh-types' -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' import { createSshIpcHarness } from './ssh-ipc-test-harness' const { mockSshStore, mockConnectionManager, - mockDeployAndLaunchRelay, - mockAcceptSshPtyOutputData, - mockAcceptSshPtyOutputExit, - mockPtyProvider, mockPortForwardManager, - mockPortScannerCallbacks, mockNextConnectionManagers, mockNextPortForwardManagers } = mocks @@ -44,7 +36,6 @@ const { describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) const { - ipcTestSource, handlers, mockStore, mockWindow, @@ -55,114 +46,6 @@ describe('SSH IPC handlers', () => { beforeEach(harness.reset) - it('preserves active port forwards and live connections across handler re-registration', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - const forward = { - id: 'pf-1', - connectionId: 'ssh-1', - localPort: 4100, - remoteHost: '127.0.0.1', - remotePort: 3000, - label: 'app' - } - const updatedForward = { ...forward, remotePort: 3001 } - const newForward = { ...forward, id: 'pf-2', localPort: 4101 } - const connectedState = { - targetId: 'ssh-1', - status: 'connected' as const, - error: null, - reconnectAttempt: 0 - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue(connectedState) - mockPortForwardManager.addForward - .mockResolvedValueOnce(forward) - .mockResolvedValueOnce(newForward) - mockPortForwardManager.updateForward.mockResolvedValue(updatedForward) - mockPortForwardManager.removeForwardAndWait.mockResolvedValue(updatedForward) - mockPortForwardManager.listForwards.mockReturnValue([forward]) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:addPortForward')!(null, { - targetId: 'ssh-1', - localPort: 4100, - remoteHost: '127.0.0.1', - remotePort: 3000, - label: 'app' - }) - const replacementConnectionManager = createConnectionManagerMock() - const replacementPortForwardManager = createPortForwardManagerMock() - mockNextConnectionManagers.push(replacementConnectionManager) - mockNextPortForwardManagers.push(replacementPortForwardManager) - - registerSshHandlers(mockStore as never, () => createMockWindow() as never) - - expect(getSshConnectionManager()).toBe(mockConnectionManager) - expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ - forward - ]) - mockDeployAndLaunchRelay.mockClear() - mockPortForwardManager.removeAllForwards.mockClear() - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - ...connectedState, - providerEpoch: expect.any(String), - connectionGeneration: 1 - }) - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() - expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ - forward - ]) - - await handlers.get('ssh:updatePortForward')!(null, { - id: 'pf-1', - targetId: 'ssh-1', - localPort: 4100, - remoteHost: '127.0.0.1', - remotePort: 3001, - label: 'app' - }) - expect(mockPortForwardManager.updateForward).toHaveBeenCalledWith( - 'pf-1', - conn, - 4100, - '127.0.0.1', - 3001, - 'app' - ) - - expect(await handlers.get('ssh:removePortForward')!(null, { id: 'pf-1' })).toEqual( - updatedForward - ) - await handlers.get('ssh:addPortForward')!(null, { - targetId: 'ssh-1', - localPort: 4101, - remoteHost: '127.0.0.1', - remotePort: 3000, - label: 'app' - }) - expect(mockPortForwardManager.addForward).toHaveBeenLastCalledWith( - 'ssh-1', - conn, - 4101, - '127.0.0.1', - 3000, - 'app' - ) - expect(replacementConnectionManager.getConnection).not.toHaveBeenCalled() - expect(replacementPortForwardManager.listForwards).not.toHaveBeenCalled() - }) - it('persists desired forwards and broadcasts when an active forward closes unexpectedly', () => { const target: SshTarget = { id: 'ssh-1', @@ -247,110 +130,6 @@ describe('SSH IPC handlers', () => { expect(replacementConnectionManager.disconnect).not.toHaveBeenCalled() }) - it('refreshes live session callbacks to the newest window and output authorities', async () => { - const firstWindow = createMockWindow() - const secondWindow = createMockWindow() - const firstRuntime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn() - } - const secondRuntime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn() - } - registerSshHandlers(mockStore as never, () => firstWindow as never, firstRuntime as never) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as SshPtyDataCallback | undefined - const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as - | ((payload: { - id: string - code: number - providerGeneration: number - ptyIncarnation: string - }) => void) - | undefined - const onDetectedPorts = mockPortScannerCallbacks.get('ssh-1') as - | ((targetId: string, ports: unknown[], platform: string) => void) - | undefined - firstWindow.webContents.send.mockClear() - secondWindow.webContents.send.mockClear() - - registerSshHandlers(mockStore as never, () => secondWindow as never, secondRuntime as never) - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: unknown) => void - } - - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'error', - error: 'network down', - reconnectAttempt: 0 - }) - onData?.({ - id: 'remote-pty', - data: 'hello', - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty', - source: ipcTestSource - }) - onExit?.({ - id: 'remote-pty', - code: 9, - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty' - }) - onDetectedPorts?.( - 'ssh-1', - [{ host: '127.0.0.1', port: 3000, pid: 12, processName: 'node' }], - 'linux-x64' - ) - - expect(firstWindow.webContents.send).not.toHaveBeenCalled() - expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'error', - error: 'network down', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 1 - } - }) - expect(mockAcceptSshPtyOutputData).toHaveBeenCalledWith( - expect.objectContaining({ id: 'remote-pty', data: 'hello' }) - ) - expect(mockAcceptSshPtyOutputExit).toHaveBeenCalledWith( - expect.objectContaining({ id: 'remote-pty', code: 9 }) - ) - expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:detected-ports-changed', { - targetId: 'ssh-1', - ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) - }) - expect(secondRuntime.onPtyData).not.toHaveBeenCalled() - expect(secondRuntime.onPtyExit).not.toHaveBeenCalled() - expect(firstRuntime.onPtyData).not.toHaveBeenCalled() - expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() - }) - it('re-registers without replacing managers when no targets are connected', () => { const replacementConnectionManager = createConnectionManagerMock() const replacementPortForwardManager = createPortForwardManagerMock() diff --git a/src/main/ipc/ssh-host-node-runtime-connect.test.ts b/src/main/ipc/ssh-host-node-runtime-connect.test.ts deleted file mode 100644 index b2c7941eb66d..000000000000 --- a/src/main/ipc/ssh-host-node-runtime-connect.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' -import { setSshHostServerStatus, clearSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { getPublicSshState } from './ssh-renderer-broadcast' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks - -const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - remoteRuntime: 'legacy' -} - -function lastConnectedState(send: ReturnType): SshConnectionState | undefined { - return ( - send.mock.calls - .filter(([channel]) => channel === 'ssh:state-changed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh:state-changed always carries { targetId, state }. - .map(([, payload]) => (payload as { state: SshConnectionState }).state) - .findLast((state) => state.status === 'connected') - ) -} - -describe('ssh:connect on the opt-in Host Node runtime', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockWindow } = harness - - beforeEach(async () => { - await harness.reset() - recordSshRelayRuntimeStep('ssh-1', false) - clearSshHostServerStatus('ssh-1') - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - }) - - it('publishes the connection as running the unsupported Host Node runtime', async () => { - mockDeployAndLaunchRelay.mockImplementationOnce(async () => { - recordSshRelayRuntimeStep('ssh-1', true) - return harness.createRelayLaunchResult() - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - expect(lastConnectedState(mockWindow.webContents.send)?.hostNodeRuntime).toBe(true) - }) - - it('stays quiet on a ladder connect and once the host moved to its managed server', async () => { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - expect(lastConnectedState(mockWindow.webContents.send)).not.toHaveProperty('hostNodeRuntime') - - recordSshRelayRuntimeStep('ssh-1', true) - setSshHostServerStatus('ssh-1', { - kind: 'managed', - environmentId: 'env-1' - }) - expect(getPublicSshState('ssh-1')).not.toHaveProperty('hostNodeRuntime') - }) -}) diff --git a/src/main/ipc/ssh-host-server-connect.ts b/src/main/ipc/ssh-host-server-connect.ts index 4d1ff5ba22cf..91d24b34cb7a 100644 --- a/src/main/ipc/ssh-host-server-connect.ts +++ b/src/main/ipc/ssh-host-server-connect.ts @@ -7,46 +7,27 @@ import type { SshTarget } from '../../shared/ssh-types' import type { HostServerOnConnectResult } from '../ssh/ssh-host-server-on-connect' -import { relayServerStatus, shouldToastManagedServerMove } from '../ssh/ssh-host-server-move-offer' +import { unservedHostServerMessage, unservedServerStatus } from '../ssh/ssh-host-unserved-status' import { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { trackSshHostServerMove } from '../ssh/ssh-host-server-telemetry' -import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { allowsDirectSshRelay } from '../ssh/ssh-connection-store' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' -import { - broadcastSshState, - clearRelayStateOverride, - getPublicSshState -} from './ssh-renderer-broadcast' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' +import { broadcastSshState, getPublicSshState } from './ssh-renderer-broadcast' import { isAuthError } from '../ssh/ssh-connection-utils' -/** Resolves null when the decision couldn't run on a host that may still use the relay. */ -export async function decideHostServer( - target: SshTarget -): Promise { - try { - // Why lazy: the managed-server graph (deploy, migration, tunnel) loads only when a host connects. - const [{ resolveHostServerOnConnect }, { hostServerOnConnectDeps }] = await Promise.all([ - import('../ssh/ssh-host-server-on-connect'), - import('./ssh-host-server-on-connect-wiring') - ]) - return await resolveHostServerOnConnect( - target, - hostServerOnConnectDeps(getAppEnvironment().getPath('userData')) - ) - } catch (error) { - // A host with no relay fallback surfaces the real failure (auth, unreachable), not a generic one. - if (!allowsDirectSshRelay(getSshTargetRegistryStore()?.getTarget(target.id) ?? target)) { - throw error - } - console.warn('[ssh] Could not decide the managed Orca server for this host:', error) - return null - } +export async function decideHostServer(target: SshTarget): Promise { + // Why lazy: the managed-server graph (deploy, migration, tunnel) loads only when a host connects. + const [{ resolveHostServerOnConnect }, { hostServerOnConnectDeps }] = await Promise.all([ + import('../ssh/ssh-host-server-on-connect'), + import('./ssh-host-server-on-connect-wiring') + ]) + return await resolveHostServerOnConnect( + target, + hostServerOnConnectDeps(getAppEnvironment().getPath('userData')) + ) } /** Rechecks a host another desktop's update held during this connect, until the fence clears. */ @@ -79,7 +60,6 @@ export function recheckWhenManagedFenceClears(target: SshTarget, environmentId: export function publishHostServerDecisionFailure(targetId: string, error: unknown): void { const failure = error instanceof Error ? error : new Error(String(error)) clearSshHostServerStatus(targetId) - clearRelayStateOverride(targetId) broadcastSshState(getCurrentMainWindow, targetId, { targetId, status: isAuthError(failure) ? 'auth-failed' : 'error', @@ -101,6 +81,7 @@ export function publishManagedServerConnect( ...(serving ? { serving } : {}) } setSshHostServerStatus(targetId, managedServer) + void restoreManagedHostPortForwards(targetId) const state: SshConnectionState = { ...(connectionManager!.getState(targetId) ?? { targetId, reconnectAttempt: 0 }), targetId, @@ -112,51 +93,23 @@ export function publishManagedServerConnect( return getPublicSshState(targetId) ?? state } -/** Records why the host keeps the relay; the first live-terminals stop this version offers a move. */ -export function recordRelayDecision( +/** + * A host the managed server doesn't serve: publish why and fail the connect. There is no fallback; + * the renderer translates the status, and this message is only its untranslated twin. + */ +export function publishUnservedHostServer( target: SshTarget, decision: Extract -): void { - const appVersion = getAppEnvironment().getVersion() - const offerMove = shouldToastManagedServerMove(target, decision, appVersion) - if (offerMove) { - getSshTargetRegistryStore()!.updateTarget(target.id, { - managedServerMoveOffered: { appVersion } - }) - trackSshHostServerMove('offered', knownSshHostPlatform(target.id)) - } - setSshHostServerStatus(target.id, relayServerStatus(decision, offerMove)) -} - -/** After the relay session is up, a terminal the first decision could not ask about may prove live. */ -export async function refineRelayTerminalDecision( - target: SshTarget, - decision: HostServerOnConnectResult | null, - isCurrent: () => boolean -): Promise { - try { - const [ - { relayTerminalsOnceConnected }, - { orcadMigrationRelayPtyLister }, - { censusHostRelayTerminalsFor } - ] = await Promise.all([ - import('../ssh/ssh-host-relay-terminals-once-connected'), - import('../ssh/orcad-migration-relay-pty-lister'), - import('../ssh/ssh-host-relay-census-for-target') - ]) - const refined = await relayTerminalsOnceConnected({ - store: getSshTargetRegistryStore()!.getOrcadMigrationSource(), - targetId: target.id, - decision, - listRelayPtyIds: orcadMigrationRelayPtyLister(target.id), - censusHost: censusHostRelayTerminalsFor(target), - isCurrent - }) - if (refined && isCurrent()) { - recordRelayDecision(target, refined) - } - } catch (error) { - // The first decision's status stands; it already keeps the host on the relay. - console.warn('[ssh] Could not re-check relay terminals after connecting:', error) - } +): Error { + const managedServer = unservedServerStatus(decision) + setSshHostServerStatus(target.id, managedServer) + const failure = new Error(unservedHostServerMessage(decision)) + broadcastSshState(getCurrentMainWindow, target.id, { + targetId: target.id, + status: 'error', + error: failure.message, + reconnectAttempt: 0, + managedServer + }) + return failure } diff --git a/src/main/ipc/ssh-host-server-decision-failure.test.ts b/src/main/ipc/ssh-host-server-decision-failure.test.ts index ff74ae9a41ea..9656ecdbb66b 100644 --- a/src/main/ipc/ssh-host-server-decision-failure.test.ts +++ b/src/main/ipc/ssh-host-server-decision-failure.test.ts @@ -37,12 +37,6 @@ const fenced: SshTarget = { beforeEach(() => vi.clearAllMocks()) describe('a managed-server decision that fails', () => { - it('keeps the relay path on a host that may still use it', async () => { - mocks.getTarget.mockReturnValue(target) - mocks.resolve.mockRejectedValue(new Error('All configured authentication methods failed')) - await expect(decideHostServer(target)).resolves.toBeNull() - }) - it('surfaces the real error on a host only its managed server can reach', async () => { mocks.getTarget.mockReturnValue(fenced) const auth = new Error('All configured authentication methods failed') diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts index 4b15671de249..a026c676ed36 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts @@ -104,7 +104,7 @@ describe('connect-time server decision against the real profile', () => { expect(deps.recordedUnavailable(target())).toBeNull() }) - it('keeps the relay while a saved relay terminal is unproven, without converting', async () => { + it('refuses while a saved relay terminal is unproven, without converting', async () => { store.addRepo({ id: 'repo-1', path: '/srv/app', @@ -117,7 +117,7 @@ describe('connect-time server decision against the real profile', () => { store.upsertSshRemotePtyLease({ targetId: TARGET.id, ptyId: 'pty-1', state: 'expired' }) await expect( resolveHostServerOnConnect(target(), hostServerOnConnectDeps(userDataPath)) - ).resolves.toEqual({ route: 'relay', reason: 'relay_terminals_unverifiable', terminals: 1 }) + ).resolves.toEqual({ route: 'relay', reason: 'relay_terminals_unverifiable', terminals: 0 }) expect(mocks.convert).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.ts index b13a9bc256df..83fd1b2b84b5 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.ts @@ -1,6 +1,9 @@ /** The live collaborators behind each connect's server decision. */ import { getAppEnvironment } from '../../shared/app-environment' -import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { + getManagedOrcadFenceEnvironmentId, + managedOrcadServerName +} from '../../shared/managed-orcad-ssh-owner' import { listEnvironments } from '../../shared/runtime-environment-store' import { findOrcadMigrationSourceCutoverForTarget } from '../ssh/orcad-migration-cutover-journal' import { orcadMigrationRelayPtyLister } from '../ssh/orcad-migration-relay-pty-lister' @@ -89,13 +92,13 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne }), deploy: (target) => createManagedOrcadEnvironment(userDataPath, { - name: target.orcadProvisioning?.name ?? target.label, + name: target.orcadProvisioning?.name ?? managedOrcadServerName(target), sshTargetId: target.id }), convert: (target, hostProof) => convertSshTargetToManagedOrcad(userDataPath, { sshTargetId: target.id, - name: target.label, + name: managedOrcadServerName(target), listRelayPtyIds: orcadMigrationRelayPtyLister(target.id), // The connect's own census already answered; a conversion before any session reuses it. censusHost: (host) => diff --git a/src/main/ipc/ssh-host-server-relay-decision.test.ts b/src/main/ipc/ssh-host-server-relay-decision.test.ts deleted file mode 100644 index b1677d6d51ea..000000000000 --- a/src/main/ipc/ssh-host-server-relay-decision.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => ({ - updateTarget: vi.fn(), - setStatus: vi.fn() -})) -vi.mock('../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getVersion: () => '1.5.0' }) -})) -vi.mock('../ssh/ssh-target-registry', () => ({ - getSshTargetRegistryStore: () => ({ updateTarget: mocks.updateTarget }) -})) -vi.mock('../ssh/ssh-host-server-status', () => ({ setSshHostServerStatus: mocks.setStatus })) -vi.mock('./ssh-ipc-context', () => ({ connectionManager: null, getCurrentMainWindow: () => null })) -vi.mock('./ssh-renderer-broadcast', () => ({ - broadcastSshState: vi.fn(), - getPublicSshState: vi.fn() -})) - -const { recordRelayDecision } = await import('./ssh-host-server-connect') - -const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } -const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 2 } - -beforeEach(() => vi.clearAllMocks()) - -describe('recording why a connect kept the relay', () => { - it('records the first offer this version and marks the status for the toast', () => { - recordRelayDecision(target, live) - expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { - managedServerMoveOffered: { appVersion: '1.5.0' } - }) - expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', { - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 2, - offerMove: true - }) - }) - - it('keeps the status line offer but skips the toast once this version offered it', () => { - recordRelayDecision({ ...target, managedServerMoveOffered: { appVersion: '1.5.0' } }, live) - expect(mocks.updateTarget).not.toHaveBeenCalled() - expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', { - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 2 - }) - }) -}) diff --git a/src/main/ipc/ssh-host-sleep-reconnect.test.ts b/src/main/ipc/ssh-host-sleep-reconnect.test.ts index e0f9721e6e03..0c3285a9c582 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.test.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.test.ts @@ -1,67 +1,28 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -const manager = vi.hoisted(() => { - const connection = {} - return { getConnection: vi.fn(() => connection), reconnect: vi.fn(async () => {}) } -}) const recoverManagedTunnels = vi.hoisted(() => vi.fn(async () => {})) vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') return { powerMonitor: new EventEmitter() } }) -vi.mock('./ssh-ipc-context', () => ({ connectionManager: manager })) vi.mock('../ssh/orcad-managed-tunnel', () => ({ recoverOrcadManagedTunnelsAfterHostResume: recoverManagedTunnels })) import { powerMonitor } from 'electron' -import { activeSessions } from './ssh-active-relay-sessions' import { registerPowerMonitorReconnect, unregisterPowerMonitorReconnect } from './ssh-host-sleep-reconnect' -function plainSession(probe: () => Promise) { - return { - prepareForHostSleep: vi.fn(), - getMux: () => null, - getPlainSshSession: () => ({ probeTransport: vi.fn(probe) }) - } -} - -async function resumeAndSettle(): Promise { - powerMonitor.emit('resume') - await vi.waitFor(() => expect(manager.getConnection).toHaveBeenCalled()) - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('host sleep reconnect in plain SSH mode', () => { +describe('host sleep reconnect', () => { afterEach(() => { unregisterPowerMonitorReconnect() - activeSessions.clear() vi.clearAllMocks() }) - it('keeps plain shells when the SSH transport still answers after resume', async () => { - const session = plainSession(async () => true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) - registerPowerMonitorReconnect() - await resumeAndSettle() - expect(manager.reconnect).not.toHaveBeenCalled() - }) - - it('reconnects when the plain SSH transport stops answering', async () => { - const session = plainSession(async () => false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) - registerPowerMonitorReconnect() - powerMonitor.emit('resume') - await vi.waitFor(() => expect(manager.reconnect).toHaveBeenCalledWith('target-1')) - }) - - it('recovers managed tunnels with the same probe policy, even with no relay sessions', async () => { + it('recovers managed tunnels with the same probe policy', async () => { registerPowerMonitorReconnect(() => '/user-data') powerMonitor.emit('resume') await vi.waitFor(() => @@ -73,11 +34,9 @@ describe('host sleep reconnect in plain SSH mode', () => { }) it('leaves managed tunnels alone when the caller supplies no profile path', async () => { - const session = plainSession(async () => true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) registerPowerMonitorReconnect() - await resumeAndSettle() + powerMonitor.emit('resume') + await new Promise((resolve) => setImmediate(resolve)) expect(recoverManagedTunnels).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/ssh-host-sleep-reconnect.ts b/src/main/ipc/ssh-host-sleep-reconnect.ts index 6bae0902a631..ff252a54505c 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.ts @@ -1,8 +1,5 @@ import { powerMonitor } from 'electron' import { recoverOrcadManagedTunnelsAfterHostResume } from '../ssh/orcad-managed-tunnel' -import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { activeSessions } from './ssh-active-relay-sessions' -import { connectionManager } from './ssh-ipc-context' import { errorMessage } from '../../shared/error-message' let powerMonitorUnsubscribe: (() => void) | null = null @@ -11,62 +8,9 @@ let powerMonitorUnsubscribe: (() => void) | null = null const RESUME_PROBE_TIMEOUT_MS = 5_000 const RESUME_PROBE_ATTEMPTS = 2 -async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise { - const plainSsh = session.getPlainSshSession() - if (plainSsh) { - // Why: plain SSH has no mux to probe, and a needless reconnect would end every open shell. - for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) { - if (await plainSsh.probeTransport(RESUME_PROBE_TIMEOUT_MS)) { - return true - } - } - return false - } - const mux = session.getMux() - if (!mux || mux.isDisposed()) { - return false - } - for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) { - if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) { - return true - } - } - return false -} - export function registerPowerMonitorReconnect(getUserDataPath?: () => string): void { powerMonitorUnsubscribe?.() - const onSuspend = (): void => { - for (const session of activeSessions.values()) { - session.prepareForHostSleep() - } - } const onResume = (): void => { - for (const [targetId, session] of activeSessions) { - const manager = connectionManager - const conn = manager?.getConnection(targetId) - if (!conn) { - continue - } - void (async () => { - // Why: unconditional reconnect on wake tore down live sessions and flashed the overlay (#7773); only reconnect if the relay link actually died during sleep. - if (await isRelayLinkAliveAfterResume(session)) { - return - } - // Why: the probe can take ~10s; bail if the session/connection was replaced or torn down meanwhile, else we'd resurrect it. - if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) { - return - } - try { - await manager?.reconnect(targetId) - } catch (err) { - console.warn( - `[ssh] Failed to reconnect ${targetId} after system resume: ${errorMessage(err)}` - ) - } - })() - } - // Why separate: managed tunnels ride their own connections, not relay sessions. if (getUserDataPath) { void recoverOrcadManagedTunnelsAfterHostResume(getUserDataPath(), { attempts: RESUME_PROBE_ATTEMPTS, @@ -78,10 +22,8 @@ export function registerPowerMonitorReconnect(getUserDataPath?: () => string): v }) } } - powerMonitor.on('suspend', onSuspend) powerMonitor.on('resume', onResume) powerMonitorUnsubscribe = () => { - powerMonitor.off('suspend', onSuspend) powerMonitor.off('resume', onResume) } } diff --git a/src/main/ipc/ssh-ipc-mock-shapes.ts b/src/main/ipc/ssh-ipc-mock-shapes.ts index 4136aa78512f..8be0d14c4312 100644 --- a/src/main/ipc/ssh-ipc-mock-shapes.ts +++ b/src/main/ipc/ssh-ipc-mock-shapes.ts @@ -74,8 +74,6 @@ export type SshIpcMockState = { powerMonitorOnMock: Mock mockSshStore: SshStoreMock mockConnectionManager: SshConnectionManagerMock - mockDeployAndLaunchRelay: Mock - mockForceStopRelayForTarget: Mock mockAcceptSshPtyOutputData: Mock mockAcceptSshPtyOutputExit: Mock mockMux: SshChannelMultiplexerMock @@ -101,8 +99,6 @@ export type SshIpcMockModules = { hostServerConnect: SshIpcMockModule sshConnectionStore: SshIpcMockModule sshConnectionManager: SshIpcMockModule - sshRelayDeploy: SshIpcMockModule - sshRelayReset: SshIpcMockModule sshChannelMultiplexer: SshIpcMockModule sshPtyProvider: SshIpcMockModule sshFilesystemProvider: SshIpcMockModule diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index 471f602a76de..ca6ef21cb1fe 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -36,8 +36,6 @@ export function createSshIpcMocks(): SshIpcMocks { setCallbacks: vi.fn(), callbacksRef: { current: null as unknown } }, - mockDeployAndLaunchRelay: vi.fn(), - mockForceStopRelayForTarget: vi.fn(), mockAcceptSshPtyOutputData: vi.fn().mockResolvedValue({}), mockAcceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), mockMux: { @@ -93,8 +91,6 @@ export function createSshIpcMocks(): SshIpcMocks { powerMonitorOnMock, mockSshStore, mockConnectionManager, - mockDeployAndLaunchRelay, - mockForceStopRelayForTarget, mockAcceptSshPtyOutputData, mockAcceptSshPtyOutputExit, mockMux, @@ -145,14 +141,21 @@ export function createSshIpcMocks(): SshIpcMocks { installSshPtySourceAckPublisher: vi.fn().mockReturnValue(() => {}), installSshPtySourceCancellationPublisher: vi.fn().mockReturnValue(() => {}) }, - // Null keeps today's relay path; the real decision is covered by its own tests. + // A managed host by default; the real decision is covered by its own tests. hostServerConnect: { - decideHostServer: vi.fn(async () => null), + decideHostServer: vi.fn(async (): Promise<{ route: 'managed'; environmentId: string }> => ({ + route: 'managed', + environmentId: 'env-test' + })), recheckWhenManagedFenceClears: vi.fn(), publishHostServerDecisionFailure: vi.fn(), - publishManagedServerConnect: vi.fn(), - recordRelayDecision: vi.fn(), - refineRelayTerminalDecision: vi.fn(async () => {}) + publishManagedServerConnect: vi.fn((targetId: string) => ({ + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0 + })), + publishUnservedHostServer: vi.fn(() => new Error('This SSH host isn’t supported')) }, sshConnectionStore: { isRuntimeOwnedSshTarget: (target: { owner?: unknown }) => target.owner !== undefined, @@ -179,12 +182,7 @@ export function createSshIpcMocks(): SshIpcMocks { } } }, - sshRelayDeploy: { - deployAndLaunchRelay: mockDeployAndLaunchRelay - }, - sshRelayReset: { - forceStopRelayForTarget: mockForceStopRelayForTarget - }, + sshChannelMultiplexer: { SshChannelMultiplexer: class MockSshChannelMultiplexer { constructor() { diff --git a/src/main/ipc/ssh-ipc-test-harness.ts b/src/main/ipc/ssh-ipc-test-harness.ts index 88b83c701193..881e6f2626ff 100644 --- a/src/main/ipc/ssh-ipc-test-harness.ts +++ b/src/main/ipc/ssh-ipc-test-harness.ts @@ -1,4 +1,4 @@ -import { expect, vi } from 'vitest' +import { vi } from 'vitest' import type { Mock } from 'vitest' import { registerSshHandlers, resetSshHandlerStateForTests } from './ssh' import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' @@ -16,8 +16,6 @@ import type { SshPortForwardManagerMock } from './ssh-ipc-mock-shapes' -export type RelayDisposeCallback = (reason: 'shutdown' | 'connection_lost') => void - /** Lease + consumer-recovery slice of the app store the SSH handlers write through. */ export type SshLeaseStoreMock = { getRepos: () => unknown[] @@ -41,12 +39,6 @@ export type SshLeaseStoreMock = { export type MockBrowserWindow = { isDestroyed: () => boolean; webContents: { send: Mock } } -export type RelayLaunchResultMock = { - transport: { write: Mock; onData: Mock; onClose: Mock } - platform: string - serverBuildId: string -} - export type SshIpcHarness = { relayBuildId: string ipcTestSource: SshIpcTestSource @@ -57,11 +49,6 @@ export type SshIpcHarness = { createConnectionManagerMock: () => SshConnectionManagerMock /** Replacement forwarders never exercise the await-able removal path. */ createPortForwardManagerMock: () => Omit - relayReconnectDelaysMs: readonly number[] - relayLostStabilizedMs: number - createRelayLaunchResult: () => RelayLaunchResultMock - getLatestRelayDisposeCallback: () => RelayDisposeCallback - useSlowRelayLaunchOnce: (delayMs: number) => void reset: () => Promise } @@ -74,8 +61,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { powerMonitorOnMock, mockSshStore, mockConnectionManager, - mockDeployAndLaunchRelay, - mockForceStopRelayForTarget, mockAcceptSshPtyOutputData, mockAcceptSshPtyOutputExit, mockMux, @@ -145,28 +130,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { setCallbacks: vi.fn(), callbacksRef: { current: null as unknown } }) - const relayReconnectDelaysMs = [500, 1000, 2000, 4000, 8000, 15_000] as const - const relayLostStabilizedMs = 5_000 - const createRelayLaunchResult = () => ({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: relayBuildId - }) - const getLatestRelayDisposeCallback = (): RelayDisposeCallback => { - const calls = mockMux.onDispose.mock.calls - const callback = calls.at(-1)?.[0] as RelayDisposeCallback | undefined - expect(callback).toBeDefined() - return callback! - } - const useSlowRelayLaunchOnce = (delayMs: number): void => { - mockDeployAndLaunchRelay.mockImplementationOnce( - () => - new Promise((resolve) => { - setTimeout(() => resolve(createRelayLaunchResult()), delayMs) - }) - ) - } - const reset = async (): Promise => { await resetSshHandlerStateForTests() handlers.clear() @@ -203,15 +166,9 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { mockConnectionManager.disconnectAll.mockReset() mockConnectionManager.setCallbacks.mockReset() mockConnectionManager.callbacksRef.current = null - mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined) mockAcceptSshPtyOutputData.mockReset().mockResolvedValue({}) mockAcceptSshPtyOutputExit.mockReset().mockResolvedValue(undefined) - mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: relayBuildId - }) mockMux.dispose.mockReset() mockMux.isDisposed.mockReset().mockReturnValue(false) mockMux.onNotification.mockReset() @@ -272,11 +229,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { createMockWindow, createConnectionManagerMock, createPortForwardManagerMock, - relayReconnectDelaysMs, - relayLostStabilizedMs, - createRelayLaunchResult, - getLatestRelayDisposeCallback, - useSlowRelayLaunchOnce, reset } } diff --git a/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts b/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts new file mode 100644 index 000000000000..022472d7d2ad --- /dev/null +++ b/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + restore: vi.fn(async () => {}), + removeAllForwards: vi.fn(async () => {}), + disconnect: vi.fn(async () => {}), + getState: vi.fn(() => ({ targetId: 't1', status: 'connected', error: null, reconnectAttempt: 0 })) +})) + +vi.mock('./ssh-managed-port-forward-restore', () => ({ restoreManagedHostPortForwards: h.restore })) +vi.mock('./ssh-ipc-context', () => ({ + connectionManager: { getState: h.getState, disconnect: h.disconnect, getConnection: vi.fn() }, + currentRuntime: null, + getCurrentMainWindow: () => null, + persistedStore: null, + portForwardManager: { removeAllForwards: h.removeAllForwards } +})) +vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + getSshTargetRegistryStore: () => null +})) + +const { handleSshConnectionStateChange } = await import('./ssh-connection-state-callbacks') +const { publishManagedServerConnect } = await import('./ssh-host-server-connect') +const { disconnectRegisteredSshTarget } = await import('./ssh-session-teardown') +const { connectInFlight } = await import('./ssh-connect-attempt-registry') +const { getSshProviderAuthority } = await import('../ssh/ssh-provider-authority') +const { clearSshHostServerStatus, setSshHostServerStatus } = + await import('../ssh/ssh-host-server-status') + +const connected = { targetId: 't1', status: 'connected', error: null, reconnectAttempt: 0 } as const + +describe('saved port forwards on a managed host', () => { + beforeEach(() => { + vi.clearAllMocks() + clearSshHostServerStatus('t1') + }) + + it('restores them when a managed connect is published', () => { + publishManagedServerConnect('t1', 'env-1') + expect(h.restore).toHaveBeenCalledWith('t1') + }) + + it('restores them when the managed host transport reconnects on its own', () => { + setSshHostServerStatus('t1', { kind: 'managed', environmentId: 'env-1' }) + handleSshConnectionStateChange('t1', { ...connected, status: 'reconnecting' }) + expect(h.restore).not.toHaveBeenCalled() + handleSshConnectionStateChange('t1', connected) + expect(h.restore).toHaveBeenCalledWith('t1') + }) + + it('leaves an explicit connect to restore through its own publish', () => { + connectInFlight.set('t1', { + authority: getSshProviderAuthority('t1'), + promise: new Promise(() => {}) + }) + try { + handleSshConnectionStateChange('t1', connected) + expect(h.restore).not.toHaveBeenCalled() + } finally { + connectInFlight.delete('t1') + } + }) + + it('closes the local listeners on disconnect even though no relay session exists', async () => { + await disconnectRegisteredSshTarget('t1') + expect(h.removeAllForwards).toHaveBeenCalledWith('t1') + }) +}) diff --git a/src/main/ipc/ssh-managed-port-forward-restore.ts b/src/main/ipc/ssh-managed-port-forward-restore.ts new file mode 100644 index 000000000000..126e7916fc96 --- /dev/null +++ b/src/main/ipc/ssh-managed-port-forward-restore.ts @@ -0,0 +1,31 @@ +import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { getCurrentMainWindow } from './ssh-ipc-context' +import { restorePortForwards } from './ssh-port-forward-persistence' + +const restoring = new Map>() + +/** A managed host has no relay session to restore its saved forwards, so its connects do. */ +export function restoreManagedHostPortForwards(targetId: string): Promise { + if (getSshHostServerStatus(targetId)?.kind !== 'managed') { + return Promise.resolve() + } + // Why chained: overlapping connects would otherwise both bind the same saved local port. + const previous = restoring.get(targetId) ?? Promise.resolve() + const next = previous + // Why re-checked: a disconnect or relay fallback queued behind the previous restore ends managed. + .then(() => + getSshHostServerStatus(targetId)?.kind === 'managed' + ? restorePortForwards(targetId, getCurrentMainWindow) + : undefined + ) + .catch((error: unknown) => { + console.warn(`[ssh] Could not restore port forwards for ${targetId}:`, error) + }) + .finally(() => { + if (restoring.get(targetId) === next) { + restoring.delete(targetId) + } + }) + restoring.set(targetId, next) + return next +} diff --git a/src/main/ipc/ssh-managed-server-move-conversion.test.ts b/src/main/ipc/ssh-managed-server-move-conversion.test.ts deleted file mode 100644 index d045165bc48a..000000000000 --- a/src/main/ipc/ssh-managed-server-move-conversion.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { OrcadMigrationManifest } from '../../shared/orcad-migration-manifest' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import { listEnvironments } from '../../shared/runtime-environment-store' -import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' -import { Store } from '../persistence/loading-store/store' -import { fakeOrcadMigrationDestination } from '../ssh/orcad-migration-destination-fake' -import { assessOrcadMigrationTerminals } from '../ssh/orcad-migration-terminal-gate' -import { SshConnectionStore } from '../ssh/ssh-connection-store' - -const mocks = vi.hoisted(() => { - const state: { targetStore: unknown } = { targetStore: null } - return { state, deploy: vi.fn() } -}) -vi.mock('../ssh/ssh-target-registry', () => ({ - getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: () => false -})) -vi.mock('../ssh/orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) -vi.mock('../ssh/orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: vi.fn() })) -vi.mock('./ssh-connect-flow', () => ({ connectTarget: vi.fn() })) -vi.mock('./ssh-terminate-sessions', () => ({ terminateSshTargetSessions: vi.fn() })) -vi.mock('./ssh-session-teardown', () => ({ teardownSshTargetTransport: vi.fn() })) -vi.mock('./ssh-host-server-connect', () => ({ publishRelayTerminalsStatus: vi.fn() })) - -const { convertSshTargetToManagedOrcad } = await import('../ssh/orcad-runtime-conversion') -const { moveSshHostToManagedServer } = await import('./ssh-managed-server-move') - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 2 -} -const HOST_ID = `ssh:${TARGET.id}` as const -const WORKTREE = 'repo-1::/srv/app' - -let userDataPath: string -let store: Store -let destination: ReturnType - -beforeEach(() => { - vi.resetAllMocks() - userDataPath = mkdtempSync(join(tmpdir(), 'orcad-move-')) - store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') }) - store.addSshTarget(TARGET) - store.addRepo({ - id: 'repo-1', - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: TARGET.id - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given. - mocks.state.targetStore = new SshConnectionStore(store as never) - destination = fakeOrcadMigrationDestination() - mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => { - const id = getManagedOrcadFenceEnvironmentId(store.getSshTarget(TARGET.id))! - if (!listEnvironments(path).some((entry) => entry.id === id)) { - addManagedOrcadEnvironment(path, { - id, - name: args.name, - pairingCode: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768/', - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - orcadDeployment: { - sshTargetId: TARGET.id, - sshTargetGeneration: 2, - localPort: 46_768, - remotePort: 6_768 - } - }) - } - return { outcome: 'created', environment: {}, activeVersion: '1.0.0' } - }) -}) - -afterEach(async () => { - await closeTestStores() - rmSync(userDataPath, { recursive: true, force: true }) -}) - -/** Open relay terminal tabs: each tab, layout leaf and pane incarnation names its relay PTY. */ -function openRelayTerminals(count: number): void { - const terminals = Array.from({ length: count }, (_, index) => ({ - tabId: `tab-term-${index + 1}`, - leafId: `11111111-1111-4111-8111-11111111111${index + 1}`, - relayPtyId: `pty2:relay:${index + 1}` - })) - store.setWorkspaceSession( - { - ...store.getWorkspaceSession(HOST_ID), - activeRepoId: 'repo-1', - activeWorktreeId: WORKTREE, - activeTabId: terminals[0].tabId, - tabsByWorktree: { - [WORKTREE]: terminals.map(({ tabId, relayPtyId }, index) => ({ - id: tabId, - ptyId: `${HOST_ID}@@${relayPtyId}`, - worktreeId: WORKTREE, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: 1 - })) - }, - terminalLayoutsByTabId: Object.fromEntries( - terminals.map(({ tabId, leafId, relayPtyId }) => [ - tabId, - { - root: { type: 'leaf' as const, leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: `${HOST_ID}@@${relayPtyId}` } - } - ]) - ), - terminalPtyIncarnationsByPaneKey: Object.fromEntries( - terminals.map(({ tabId, leafId }) => [`${tabId}:${leafId}`, `incarnation-${tabId}`]) - ), - activeWorktreeIdsOnShutdown: [WORKTREE] - }, - HOST_ID - ) - for (const { tabId, leafId, relayPtyId } of terminals) { - store.upsertSshRemotePtyLease({ - targetId: TARGET.id, - ptyId: relayPtyId, - worktreeId: WORKTREE, - tabId, - leafId, - state: 'detached' - }) - } -} - -function moveDeps() { - let status: SshManagedServerStatus | undefined - return { - getTarget: (targetId: string) => store.getSshTarget(targetId), - // The relay acknowledged stopping every terminal. - terminate: vi.fn(async (targetId: string) => { - const leases = store.getSshRemotePtyLeases(targetId) - for (const lease of leases) { - store.markSshRemotePtyLease(targetId, lease.ptyId, 'terminated') - } - return { terminated: leases.length, unverifiable: 0 } - }), - // The reconnect's server decision: its census (no relay session, so the host's census, which - // found no relay endpoint, decides with the leases) keeps the relay or lets it convert. - connect: vi.fn(async (targetId: string) => { - const proof = await assessOrcadMigrationTerminals(store, targetId, null, hostIdle) - if (proof.verdict !== 'exited') { - status = { - kind: 'relay', - reason: - proof.verdict === 'live' ? 'relay_terminals_live' : 'relay_terminals_unverifiable', - terminals: proof.ptyIds.length - } - return - } - const result = await convertSshTargetToManagedOrcad(userDataPath, { - sshTargetId: targetId, - name: TARGET.label, - listRelayPtyIds: null, - censusHost: hostIdle, - destinationFor: () => destination, - releaseDirectSession: async () => {} - }) - status = - result.outcome === 'converted' - ? { kind: 'managed', environmentId: result.environment.id } - : { kind: 'relay', reason: 'refused', detail: 'reason' in result ? result.reason : '' } - }), - serverStatus: () => status, - report: vi.fn(), - releaseRelay: vi.fn(async () => {}) - } -} - -async function hostIdle() { - return { verdict: 'exited' as const, count: 0 } -} - -function stagedSession() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stage receives the migration manifest as its first argument. - const manifest = destination.stage.mock.calls[0]?.[0] as OrcadMigrationManifest - return manifest.payload.dormantState?.workspaceSession -} - -describe('moving a host whose live relay terminals kept it on the relay', () => { - it('stops the terminal, converts, and carries its tab to orcad without the relay PTY', async () => { - openRelayTerminals(1) - const deps = moveDeps() - - await expect(moveSshHostToManagedServer(TARGET.id, deps)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(deps.terminate).toHaveBeenCalledWith(TARGET.id, expect.any(Function)) - const session = stagedSession() - // No relay PTY id survives, so the tab spawns a fresh shell on the managed server. - expect(Object.values(session?.tabsByWorktree ?? {}).flat()).toMatchObject([ - { id: 'tab-term-1', ptyId: null } - ]) - expect(session?.terminalLayoutsByTabId?.['tab-term-1']?.ptyIdsByLeafId).toBeUndefined() - expect(session?.terminalPtyIncarnationsByPaneKey ?? {}).toEqual({}) - }) - - it('moves two terminals whose stop lost its last reply to a relay that hung up (BUG-15)', async () => { - openRelayTerminals(2) - const deps = moveDeps() - // Both shells died and their leases recorded it, but the second reply was lost. - deps.terminate.mockImplementationOnce(async (targetId: string) => { - store.markSshRemotePtyLease(targetId, 'pty2:relay:1', 'terminated') - store.markSshRemotePtyLease(targetId, 'pty2:relay:2', 'terminated') - throw new Error('Failed to terminate SSH host sessions: pty2:relay:2: Multiplexer disposed') - }) - - await expect(moveSshHostToManagedServer(TARGET.id, deps)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(deps.releaseRelay).toHaveBeenCalledWith(TARGET.id) - expect(Object.values(stagedSession()?.tabsByWorktree ?? {}).flat()).toMatchObject([ - { id: 'tab-term-1', ptyId: null }, - { id: 'tab-term-2', ptyId: null } - ]) - }) - - it('refuses and refreshes the status when a failed stop left a terminal unproven', async () => { - openRelayTerminals(2) - const deps = moveDeps() - deps.terminate.mockImplementationOnce(async (targetId: string) => { - store.markSshRemotePtyLease(targetId, 'pty2:relay:1', 'terminated') - throw new Error('Failed to terminate SSH host sessions: pty2:relay:2: Multiplexer disposed') - }) - - await expect(moveSshHostToManagedServer(TARGET.id, deps)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 1 - }) - // Reconnected on the relay, whose own gate refuses the conversion the same way. - expect(deps.connect).toHaveBeenCalledWith(TARGET.id) - expect(destination.stage).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/ssh-managed-server-move-stop-retention.test.ts b/src/main/ipc/ssh-managed-server-move-stop-retention.test.ts deleted file mode 100644 index ff26ef17b040..000000000000 --- a/src/main/ipc/ssh-managed-server-move-stop-retention.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock( - '../ssh/ssh-previous-relay-terminals', - () => import('../ssh/ssh-previous-relay-census-test-double') -) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { Repo } from '../../shared/repo-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' -import { getSshPtyProvider, getPtyIdsForConnection } from './pty' -import { ptyIncarnationById } from './pty/provider/ownership-state' -import { setCurrentRuntime } from './ssh-ipc-context' -import { createSshIpcHarness } from './ssh-ipc-test-harness' -import { terminateSshTargetSessions } from './ssh-terminate-sessions' - -const { mockSshStore, mockPtyProvider } = mocks -const worktreeId = 'repo::/srv/repo' -const leafId = '11111111-1111-4111-8111-111111111111' -const stoppedShell = 'ssh:ssh-1@@pty2:epoch:1' -const userExitedShell = 'ssh:ssh-1@@pty2:epoch:2' -const repo: Repo = { - id: 'repo', - path: '/srv/repo', - connectionId: 'ssh-1', - displayName: 'repo', - badgeColor: 'gray', - addedAt: 1 -} - -function sessionWith(ptyIds: Record): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [worktreeId]: Object.entries(ptyIds).map(([tabId, ptyId], sortOrder) => ({ - id: tabId, - worktreeId, - ptyId, - title: `Terminal ${sortOrder + 1}`, - customTitle: null, - color: null, - sortOrder, - createdAt: 1 - })) - }, - terminalLayoutsByTabId: Object.fromEntries( - Object.entries(ptyIds).map(([tabId, ptyId]) => [ - tabId, - { - root: { type: 'leaf', leafId: `${leafId.slice(0, -1)}${tabId.at(-1)}` }, - activeLeafId: `${leafId.slice(0, -1)}${tabId.at(-1)}`, - expandedLeafId: null, - ptyIdsByLeafId: { [`${leafId.slice(0, -1)}${tabId.at(-1)}`]: ptyId } - } - ]) - ) - } -} - -describe("a move's stop keeps the saved SSH session it is about to convert", () => { - const harness = createSshIpcHarness(mocks) - beforeEach(harness.reset) - afterEach(() => setCurrentRuntime(undefined)) - - it('keeps the stopped tab through its real exit, certifies the death, and closes an earlier exit', async () => { - let session = sessionWith({ 'tab-1': stoppedShell, 'tab-2': userExitedShell }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake supplies the session and repo methods exit retirement reads. - const store = withDurableRuntimeStore({ - getRepos: () => [repo], - getRepo: () => repo, - getWorkspaceSessionHostIds: () => ['ssh:ssh-1'], - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - session = next - }, - flushOrThrow: vi.fn() - }) as never - const runtime = new OrcaRuntimeService(store) - for (const [tabId, ptyId] of [ - ['tab-1', stoppedShell], - ['tab-2', userExitedShell] - ] as const) { - const tabLeafId = `${leafId.slice(0, -1)}${tabId.at(-1)}` - runtime.registerPty(ptyId, worktreeId, 'ssh-1', { - tabId, - leafId: tabLeafId, - incarnationId: `inc-${tabId}` - }) - ptyIncarnationById.set(ptyId, `inc-${tabId}`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a real OrcaRuntimeService. - setCurrentRuntime(runtime as never) - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([stoppedShell]) - - // The user's own exit lands before the move stops anything: it closes its tab as always. - await runtime.onPtyExit(userExitedShell, 0, 'inc-tab-2', { hostExitConfirmed: true }) - expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) - - // The relay reports the stopped shell's real exit while its shutdown is in flight. - mockPtyProvider.shutdown.mockImplementation(async (ptyId: string) => { - await runtime.onPtyExit(ptyId, 0, 'inc-tab-1', { hostExitConfirmed: true }) - }) - const stopped: string[] = [] - await terminateSshTargetSessions('ssh-1', { - intentionalStop: 'replaced', - onStopped: (ptyId) => stopped.push(ptyId) - }) - - expect(stopped).toEqual([stoppedShell]) - expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) - expect(runtime.getPtyLivenessVerdict(stoppedShell)?.status).toBe('exited') - }, 120_000) -}) diff --git a/src/main/ipc/ssh-managed-server-move.test.ts b/src/main/ipc/ssh-managed-server-move.test.ts deleted file mode 100644 index fb30f525a3d4..000000000000 --- a/src/main/ipc/ssh-managed-server-move.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' - -vi.mock('./ssh-connect-flow', () => ({ connectTarget: vi.fn() })) -vi.mock('./ssh-terminate-sessions', () => ({ terminateSshTargetSessions: vi.fn() })) -vi.mock('./ssh-session-teardown', () => ({ teardownSshTargetTransport: vi.fn() })) - -const { moveSshHostToManagedServer } = await import('./ssh-managed-server-move') - -const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } -const live = (terminals: number): SshManagedServerStatus => ({ - kind: 'relay', - reason: 'relay_terminals_live', - terminals -}) - -/** `afterConnect` is what the reconnect's own decision (and its census) recorded. */ -function deps(options: { unverifiable?: number; afterConnect?: SshManagedServerStatus } = {}) { - const calls: string[] = [] - let status: SshManagedServerStatus | undefined = live(2) - return { - calls, - getTarget: vi.fn(() => target), - terminate: vi.fn(async (_targetId: string, _onStopped: (appPtyId: string) => void) => { - calls.push('terminate') - return { terminated: 2, unverifiable: options.unverifiable ?? 0 } - }), - connect: vi.fn(async () => { - calls.push('connect') - status = options.afterConnect ?? { kind: 'managed', environmentId: 'env-1' } - }), - serverStatus: vi.fn(() => status), - report: vi.fn(), - releaseRelay: vi.fn(async () => { - calls.push('release') - }) - } -} - -describe('moving an SSH host to its managed server on request', () => { - it('names the shells it stopped, even when a later stop fails, so only those tabs restart', async () => { - const move = deps({ afterConnect: live(1) }) - move.terminate.mockImplementationOnce(async (_targetId, onStopped) => { - onStopped('ssh:ssh-1@@pty-1') - throw new Error('Failed to terminate SSH host sessions: pty-2: mux down') - }) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'refused', - verdict: 'live', - terminals: 1, - stoppedPtyIds: ['ssh:ssh-1@@pty-1'] - }) - }) - - it('stops the relay terminals, then lets the reconnect prove them exited and convert', async () => { - const move = deps() - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'moved', - environmentId: 'env-1' - }) - // No census of its own: the connect's decision runs it while holding the raw 'connected'. - expect(move.calls).toEqual(['terminate', 'connect']) - expect(move.report).toHaveBeenCalledWith('ssh-1', 'moved') - }) - - it('refuses without converting when the stop could not reach every terminal', async () => { - const move = deps({ - unverifiable: 1, - afterConnect: { kind: 'relay', reason: 'relay_terminals_unverifiable', terminals: 1 } - }) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 1 - }) - expect(move.report).toHaveBeenCalledWith('ssh-1', 'refused_unverifiable') - }) - - it('reports the reconnect census verdict when it keeps the relay', async () => { - const stillLive = deps({ afterConnect: live(1) }) - await expect(moveSshHostToManagedServer('ssh-1', stillLive)).resolves.toEqual({ - outcome: 'refused', - verdict: 'live', - terminals: 1 - }) - expect(stillLive.report).toHaveBeenCalledWith('ssh-1', 'refused_live') - - const unproven = deps({ - afterConnect: { kind: 'relay', reason: 'relay_terminals_unverifiable', terminals: 3 } - }) - await expect(moveSshHostToManagedServer('ssh-1', unproven)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 3 - }) - }) - - it('reports a connect that kept the relay for another reason', async () => { - const move = deps({ afterConnect: { kind: 'relay', reason: 'refused', detail: 'blocked' } }) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ outcome: 'stayed' }) - expect(move.report).toHaveBeenCalledWith('ssh-1', 'stayed') - }) - - it('reattaches the relay first when preserved terminals need one to be stopped', async () => { - const move = deps() - move.terminate.mockRejectedValueOnce( - new Error(`${SSH_TERMINATE_RECONNECT_REQUIRED}: reconnect`) - ) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(move.calls).toEqual(['connect', 'terminate', 'connect']) - }) - - it('detaches a relay a failed stop left up, so the reconnect decides again', async () => { - const move = deps() - // BUG-15: the second terminal's reply was lost to a relay that hung up on its last exit. - move.terminate.mockRejectedValueOnce( - new Error('Failed to terminate SSH host sessions: pty2:a:3: Multiplexer disposed') - ) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(move.calls).toEqual(['release', 'connect']) - }) - - it('leaves the relay of a stop that succeeded to the reconnect', async () => { - const move = deps() - await moveSshHostToManagedServer('ssh-1', move) - expect(move.releaseRelay).not.toHaveBeenCalled() - }) - - it('reports the stop’s own unverifiable count when the reconnect itself fails', async () => { - const move = deps({ unverifiable: 2 }) - move.connect.mockRejectedValueOnce(new Error('auth failed')) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 2 - }) - }) -}) diff --git a/src/main/ipc/ssh-managed-server-move.ts b/src/main/ipc/ssh-managed-server-move.ts deleted file mode 100644 index 3b89256c516c..000000000000 --- a/src/main/ipc/ssh-managed-server-move.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * The user's "Move to managed server": stop the host's relay terminals, then reconnect so the - * connect-time decision proves them exited with its own census and runs the conversion. - */ -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move' -import type { - SshManagedServerStatus, - SshTarget, - SshTerminateSessionsResult -} from '../../shared/ssh-types' -import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { - trackSshHostServerMove, - type SshHostServerMoveOutcome -} from '../ssh/ssh-host-server-telemetry' -import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { connectTarget } from './ssh-connect-flow' -import { teardownSshTargetTransport } from './ssh-session-teardown' -import { runTargetLifecycle } from './ssh-target-lifecycle-queue' -import { terminateSshTargetSessions } from './ssh-terminate-sessions' - -export type SshManagedServerMoveDeps = { - getTarget: (targetId: string) => SshTarget | undefined - /** Stops the relay terminals as main's own restart, reporting each shell it stopped. */ - terminate: ( - targetId: string, - onStopped: (appPtyId: string) => void - ) => Promise - connect: (targetId: string) => Promise - serverStatus: (targetId: string) => SshManagedServerStatus | undefined - report: (targetId: string, outcome: SshHostServerMoveOutcome) => void - /** Detaches a relay session a failed stop left up, keeping its terminals' leases. */ - releaseRelay: (targetId: string) => Promise -} - -export async function moveSshHostToManagedServer( - targetId: string, - deps: SshManagedServerMoveDeps = defaultMoveDeps() -): Promise { - let result: SshManagedServerMoveResult | null = null - try { - result = await moveHost(targetId, deps) - return result - } finally { - deps.report(targetId, moveOutcome(result)) - } -} - -function moveOutcome(result: SshManagedServerMoveResult | null): SshHostServerMoveOutcome { - if (!result) { - return 'failed' - } - return result.outcome === 'refused' ? `refused_${result.verdict}` : result.outcome -} - -async function moveHost( - targetId: string, - deps: SshManagedServerMoveDeps -): Promise { - if (!deps.getTarget(targetId)) { - throw new Error(`SSH target "${targetId}" not found`) - } - // Why the reconnect's census decides, not the stop: a stop can fail after its shells died (a - // relay that hung up on its last exit), and the connect's decision runs the census the - // conversion trusts, while that connect holds the raw transport's 'connected'. - const stoppedPtyIds = new Set() - const stopped = await stopRelayTerminals(targetId, deps, (appPtyId) => - stoppedPtyIds.add(appPtyId) - ).catch((error: unknown) => { - console.warn('[ssh] Stopping relay terminals for the move failed; asking the census:', error) - return null - }) - if (!stopped) { - // The failed stop left the relay up; a live session would make the reconnect a no-op refresh. - await deps.releaseRelay(targetId) - } - const status = await deps.connect(targetId).then( - () => deps.serverStatus(targetId), - (error: unknown) => { - console.warn('[ssh] Reconnecting for the move failed:', error) - return undefined - } - ) - if (status?.kind === 'managed') { - return { outcome: 'moved', environmentId: status.environmentId } - } - const restart = stoppedPtyIds.size > 0 ? { stoppedPtyIds: [...stoppedPtyIds] } : {} - // Why: an unreached shell is never evidence that it exited (ssh-execution-boundary.md). - if (stopped && stopped.unverifiable > 0) { - return { - outcome: 'refused', - verdict: 'unverifiable', - terminals: stopped.unverifiable, - ...restart - } - } - if (status?.kind === 'relay' && status.reason === 'relay_terminals_live') { - return { outcome: 'refused', verdict: 'live', terminals: status.terminals ?? 0, ...restart } - } - if (status?.kind === 'relay' && status.reason === 'relay_terminals_unverifiable') { - return { - outcome: 'refused', - verdict: 'unverifiable', - terminals: status.terminals ?? 0, - ...restart - } - } - return { outcome: 'stayed', ...restart } -} - -/** Mirrors the renderer's terminate: preserved shells need a fresh relay before they can be stopped. */ -async function stopRelayTerminals( - targetId: string, - deps: SshManagedServerMoveDeps, - onStopped: (appPtyId: string) => void -): Promise { - try { - return await deps.terminate(targetId, onStopped) - } catch (error) { - if (!(error instanceof Error) || !error.message.includes(SSH_TERMINATE_RECONNECT_REQUIRED)) { - throw error - } - await deps.connect(targetId) - return deps.terminate(targetId, onStopped) - } -} - -function defaultMoveDeps(): SshManagedServerMoveDeps { - return { - getTarget: (targetId) => getSshTargetRegistryStore()!.getTarget(targetId), - // Why 'replaced': the move restarts these terminals, so main and every viewer keep their tabs. - terminate: (targetId, onStopped) => - terminateSshTargetSessions(targetId, { intentionalStop: 'replaced', onStopped }), - connect: connectTarget, - serverStatus: getSshHostServerStatus, - report: (targetId, outcome) => trackSshHostServerMove(outcome, knownSshHostPlatform(targetId)), - releaseRelay: (targetId) => - runTargetLifecycle(targetId, () => - teardownSshTargetTransport(targetId, (session) => session.detachAndPersist()) - ) - } -} diff --git a/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts b/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts index 43fb243f54da..67a57c87f511 100644 --- a/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts +++ b/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts @@ -18,15 +18,13 @@ vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ getSshTargetRegistryStore: () => null })) -const { clearPublishedManagedServer, relayStateOverrides } = - await import('./ssh-renderer-broadcast') +const { clearPublishedManagedServer } = await import('./ssh-renderer-broadcast') const { getSshHostServerStatus, setSshHostServerStatus } = await import('../ssh/ssh-host-server-status') describe('clearing a host’s managed server once it is unlinked', () => { beforeEach(() => { vi.clearAllMocks() - relayStateOverrides.clear() }) it('forgets the environment and republishes the connection without it', () => { @@ -49,19 +47,6 @@ describe('clearing a host’s managed server once it is unlinked', () => { ) }) - it('strips the environment from a relay override too', () => { - relayStateOverrides.set('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - managedServer: { kind: 'managed', environmentId: 'env-1' } - }) - clearPublishedManagedServer('ssh-1') - expect(relayStateOverrides.get('ssh-1')).not.toHaveProperty('managedServer') - expect(mocks.notify.mock.calls[0]?.[1]).not.toHaveProperty('managedServer') - }) - it('only clears when the host has no connection to republish', () => { setSshHostServerStatus('ssh-1', { kind: 'managed', environmentId: 'env-1' }) mocks.getState.mockReturnValue(undefined) diff --git a/src/main/ipc/ssh-plain-ssh-connect.test.ts b/src/main/ipc/ssh-plain-ssh-connect.test.ts deleted file mode 100644 index 65583ca681cb..000000000000 --- a/src/main/ipc/ssh-plain-ssh-connect.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError -} from '../ssh/ssh-relay-runtime-resolution' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks - -function noexecHome(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('ssh-1', null, true) - run.refused('A', 'noexec') - return new RemoteRuntimeUnavailableError(run) -} - -function connectedStates(send: ReturnType): SshConnectionState[] { - return ( - send.mock.calls - .filter(([channel]) => channel === 'ssh:state-changed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh:state-changed always carries { targetId, state }. - .map(([, payload]) => (payload as { state: SshConnectionState }).state) - .filter((state) => state.status === 'connected') - ) -} - -describe('ssh:connect when no Orca runtime can run on the host', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockWindow } = harness - - beforeEach(harness.reset) - - it('connects in plain SSH mode, publishes the reason, and keeps it across a refresh', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = { usesSystemSshTransport: () => false, shell: vi.fn(), sftp: vi.fn() } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockDeployAndLaunchRelay.mockRejectedValue(noexecHome()) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const [connected] = connectedStates(mockWindow.webContents.send) - expect(connected?.plainSsh).toEqual({ - reason: 'home_noexec', - message: expect.stringContaining('the home directory is mounted noexec') - }) - - // A window reactivation re-fires ssh:connect; it must not redeploy and kill the plain shells. - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/ipc/ssh-port-forward-handlers.ts b/src/main/ipc/ssh-port-forward-handlers.ts index 5b936fff5e36..6154cee79d7b 100644 --- a/src/main/ipc/ssh-port-forward-handlers.ts +++ b/src/main/ipc/ssh-port-forward-handlers.ts @@ -3,7 +3,6 @@ import { enrichSshForwardEntries, getWorktreeIdsForConnection } from '../ports/ssh-advertised-url-enrichment' -import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager, getCurrentMainWindow, @@ -101,8 +100,7 @@ export function registerSshPortForwardHandlers(): void { }) ipcMain.handle('ssh:listDetectedPorts', (_event, args: { targetId: string }) => { - const session = activeSessions.get(args.targetId) - const ports = session?.getPortScanner()?.getDetectedPorts(args.targetId) ?? [] - return enrichDetected(args.targetId, ports) + // Detected ports came from the relay's port scanner; a managed host reports them through its runtime. + return enrichDetected(args.targetId, []) }) } diff --git a/src/main/ipc/ssh-port-forward-persistence.ts b/src/main/ipc/ssh-port-forward-persistence.ts index e0407f859f5c..701858c02eea 100644 --- a/src/main/ipc/ssh-port-forward-persistence.ts +++ b/src/main/ipc/ssh-port-forward-persistence.ts @@ -4,6 +4,10 @@ import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { connectionManager, portForwardManager } from './ssh-ipc-context' import { broadcastPortForwards } from './ssh-renderer-broadcast' +function forwardKey(forward: SavedPortForward): string { + return `${forward.localPort}:${forward.remoteHost}:${forward.remotePort}` +} + // Why: after user add/remove/update the runtime manager is the source of truth — persist exactly its entries (unrestored ones handled by a separate helper). export function persistPortForwards(targetId: string): void { const active = portForwardManager!.listForwards(targetId) @@ -21,12 +25,10 @@ export function persistPortForwards(targetId: string): void { // Why: keep forwards that failed to restore in the persisted list so they retry on next reconnect instead of being silently dropped. export function persistPortForwardsWithUnrestored(targetId: string): void { const active = portForwardManager!.listForwards(targetId) - const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`)) + const activeKeys = new Set(active.map(forwardKey)) const existing = getSshTargetRegistryStore()!.getTarget(targetId)?.portForwards ?? [] - const unrestored = existing.filter( - (pf) => !activeKeys.has(`${pf.localPort}:${pf.remoteHost}:${pf.remotePort}`) - ) + const unrestored = existing.filter((pf) => !activeKeys.has(forwardKey(pf))) const saved: SavedPortForward[] = [ ...active.map((f) => ({ @@ -54,15 +56,21 @@ export async function restorePortForwards( if (!conn) { return } + // Why: a forward bound to the replaced transport still holds its local port but reaches nothing. + await portForwardManager!.closeStaleForwards(targetId, conn) + const live = new Set(portForwardManager!.listForwards(targetId).map(forwardKey)) // Why: keep failed restores in persisted state — a failure may be transient (port temporarily busy), so retry on next reconnect. for (const saved of target.portForwards) { + if (live.has(forwardKey(saved))) { + continue + } // Why: a reconnect mid-loop swaps the connection object; bail on identity change so we don't add forwards to a stale conn (leaking listeners). if (connectionManager!.getConnection(targetId) !== conn) { return } try { - await portForwardManager!.addForward( + const entry = await portForwardManager!.addForward( targetId, conn, saved.localPort, @@ -70,6 +78,11 @@ export async function restorePortForwards( saved.remotePort, saved.label ) + // Why: a disconnect during the add already swept this target's forwards; this one came after. + if (connectionManager!.getConnection(targetId) !== conn) { + await portForwardManager!.removeForwardAndWait(entry.id) + return + } } catch (err) { console.warn( `[ssh] Failed to restore forward :${saved.localPort} → ${saved.remoteHost}:${saved.remotePort}: ${err instanceof Error ? err.message : String(err)}` diff --git a/src/main/ipc/ssh-port-forward-restore.test.ts b/src/main/ipc/ssh-port-forward-restore.test.ts new file mode 100644 index 000000000000..47cf93309766 --- /dev/null +++ b/src/main/ipc/ssh-port-forward-restore.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PortForwardEntry, SavedPortForward } from '../../shared/ssh-types' + +const h = vi.hoisted(() => { + const conn = { id: 'conn' } + const saved: { portForwards?: SavedPortForward[] } = {} + const active: PortForwardEntry[] = [] + return { + conn, + saved, + active, + store: { + getTarget: vi.fn(() => ({ id: 't1', ...saved })), + updateTarget: vi.fn((_id: string, update: { portForwards?: SavedPortForward[] }) => { + saved.portForwards = update.portForwards + }) + }, + connectionManager: { getConnection: vi.fn<() => { id: string } | undefined>(() => conn) }, + portForwardManager: { + closeStaleForwards: vi.fn(async () => {}), + listForwards: vi.fn(() => [...active]), + addForward: vi.fn( + async ( + connectionId: string, + _conn: unknown, + localPort: number, + remoteHost: string, + remotePort: number, + label?: string + ) => { + const entry = { + id: `pf-${active.length + 1}`, + connectionId, + localPort, + remoteHost, + remotePort, + label + } + active.push(entry) + return entry + } + ), + removeForwardAndWait: vi.fn(async (id: string) => { + const index = active.findIndex((entry) => entry.id === id) + return index !== -1 ? active.splice(index, 1)[0] : null + }) + }, + hostServerStatus: vi.fn<() => { kind: 'managed'; environmentId: string } | undefined>(() => ({ + kind: 'managed', + environmentId: 'env-1' + })), + broadcastPortForwards: vi.fn() + } +}) + +vi.mock('../ssh/ssh-target-registry', () => ({ getSshTargetRegistryStore: () => h.store })) +vi.mock('./ssh-ipc-context', () => ({ + connectionManager: h.connectionManager, + portForwardManager: h.portForwardManager, + getCurrentMainWindow: () => null +})) +vi.mock('./ssh-renderer-broadcast', () => ({ broadcastPortForwards: h.broadcastPortForwards })) +vi.mock('../ssh/ssh-host-server-status', () => ({ getSshHostServerStatus: h.hostServerStatus })) + +import { restorePortForwards } from './ssh-port-forward-persistence' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' + +const FORWARD = { localPort: 4100, remoteHost: '127.0.0.1', remotePort: 7860, label: 'app' } + +describe('saved port forward restore', () => { + beforeEach(() => { + vi.clearAllMocks() + h.active.length = 0 + h.saved.portForwards = [{ ...FORWARD }] + }) + + it('closes forwards bound to a replaced transport before re-adding the saved ones', async () => { + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.closeStaleForwards).toHaveBeenCalledWith('t1', h.conn) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.portForwardManager.closeStaleForwards.mock.invocationCallOrder[0]).toBeLessThan( + h.portForwardManager.addForward.mock.invocationCallOrder[0] + ) + }) + + it('leaves a forward that is still live on the current transport alone', async () => { + await restorePortForwards('t1', () => null) + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.saved.portForwards).toEqual([FORWARD]) + }) + + it('restores a managed host once even when two connects race', async () => { + await Promise.all([restoreManagedHostPortForwards('t1'), restoreManagedHostPortForwards('t1')]) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.broadcastPortForwards).toHaveBeenCalledWith(expect.any(Function), 't1') + }) + + it('does nothing for a host that is not managed', async () => { + h.hostServerStatus.mockReturnValueOnce(undefined) + await restoreManagedHostPortForwards('t1') + expect(h.portForwardManager.addForward).not.toHaveBeenCalled() + }) + + it('closes a forward whose add finished after the host disconnected', async () => { + h.portForwardManager.addForward.mockImplementationOnce( + async (connectionId, _conn, localPort) => { + const entry = { + id: 'pf-late', + connectionId, + localPort, + remoteHost: '127.0.0.1', + remotePort: 7860, + label: undefined + } + h.active.push(entry) + h.connectionManager.getConnection.mockReturnValue(undefined) + return entry + } + ) + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.removeForwardAndWait).toHaveBeenCalledWith('pf-late') + expect(h.active).toEqual([]) + h.connectionManager.getConnection.mockReturnValue(h.conn) + }) + + it('skips a queued managed restore once the host stopped being managed', async () => { + let release: () => void = () => {} + h.portForwardManager.closeStaleForwards.mockImplementationOnce( + () => new Promise((resolve) => (release = resolve)) + ) + const first = restoreManagedHostPortForwards('t1') + await vi.waitFor(() => expect(h.portForwardManager.closeStaleForwards).toHaveBeenCalled()) + const second = restoreManagedHostPortForwards('t1') + h.hostServerStatus.mockReturnValue(undefined) + release() + await Promise.all([first, second]) + expect(h.portForwardManager.closeStaleForwards).toHaveBeenCalledTimes(1) + h.hostServerStatus.mockReturnValue({ kind: 'managed', environmentId: 'env-1' }) + }) +}) diff --git a/src/main/ipc/ssh-pty-consumer-identity.test.ts b/src/main/ipc/ssh-pty-consumer-identity.test.ts deleted file mode 100644 index de2d3abb4c56..000000000000 --- a/src/main/ipc/ssh-pty-consumer-identity.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import type { SshTarget } from '../../shared/ssh-types' -import { getSshPtyConsumerRecovery } from '../ssh/ssh-pty-consumer-recovery' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockMux } = mocks - -describe('SSH IPC handlers', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(harness.reset) - - describe('SSH PTY consumer identity across failed connects', () => { - function makeTarget(id: string): SshTarget { - return { id, label: 'Server', host: 'example.com', port: 22, username: 'deploy' } - } - - function markConnected(targetId: string): void { - mockConnectionManager.getState.mockReturnValue({ - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - } - - it('reclaims the consumer identity after a failed transport connect', async () => { - const targetId = 'ssh-consumer-identity-connect-failure' - let settleLeasePersistence!: () => void - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce( - () => - new Promise((resolve) => { - settleLeasePersistence = resolve - }) - ) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) - - const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) as Promise - const failure = expect(failedConnect).rejects.toThrow('transport refused') - const settled = vi.fn() - void failedConnect.then(settled, settled) - - await vi.waitFor(() => - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'detached') - ) - expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalled() - // Why: the connect rejection is gated on the durable 'detached' write, so the retry it - // triggers cannot re-mark leases 'attached' ahead of the abandoned session's release. - expect(settled).not.toHaveBeenCalled() - - settleLeasePersistence() - await failure - const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId - expect(claimedId).toEqual(expect.any(String)) - - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) - expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( - expect.objectContaining({ targetId, clientInstanceId: claimedId }) - ) - }) - - it('releases the abandoned leases before a fast reconnect re-owns them', async () => { - const targetId = 'ssh-consumer-identity-fast-reconnect' - const order: string[] = [] - let settleLeaseRelease!: () => void - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce((_id: string, state: string) => { - order.push(`leases:${state}`) - return new Promise((resolve) => { - settleLeaseRelease = () => { - order.push(`leases:${state}:persisted`) - resolve() - } - }) - }) - mockStore.upsertSshPtyConsumerRecovery.mockImplementation(async () => { - order.push('recovery:upsert') - }) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) - - const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) - const failure = expect(failedConnect).rejects.toThrow('transport refused') - await vi.waitFor(() => expect(order).toContain('leases:detached')) - expect(order).not.toContain('leases:detached:persisted') - - settleLeaseRelease() - await failure - - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - await handlers.get('ssh:connect')!(null, { targetId }) - - // Why: the reclaimed owner is only re-persisted after the abandoned 'detached' write landed, - // so no late release can strand the reconnected leases in 'detached'. - expect(order).toEqual(['leases:detached', 'leases:detached:persisted', 'recovery:upsert']) - }) - - it('holds a retry that starts while the detach write is still pending', async () => { - const targetId = 'ssh-consumer-identity-pending-retry' - const order: string[] = [] - let settleLeaseRelease!: () => void - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce((_id: string, state: string) => { - order.push(`leases:${state}`) - return new Promise((resolve) => { - settleLeaseRelease = () => { - order.push(`leases:${state}:persisted`) - resolve() - } - }) - }) - mockStore.upsertSshPtyConsumerRecovery.mockImplementation(async () => { - order.push('recovery:upsert') - }) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) - - const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) - const failure = expect(failedConnect).rejects.toThrow('transport refused') - await vi.waitFor(() => expect(order).toContain('leases:detached')) - - // Retry mid-write: it must not mint a session or re-own leases while the release is pending. - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - const retry = handlers.get('ssh:connect')!(null, { targetId }) as Promise - const retrySettled = vi.fn() - void retry.then(retrySettled, retrySettled) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(retrySettled).not.toHaveBeenCalled() - expect(order).not.toContain('leases:detached:persisted') - - settleLeaseRelease() - await failure - // Why: the retry folds onto the still-latched attempt rather than starting a second connect, - // so it inherits that attempt's failure instead of racing its teardown. - await expect(retry).rejects.toThrow('transport refused') - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(order).toEqual(['leases:detached', 'leases:detached:persisted']) - - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(order).toEqual(['leases:detached', 'leases:detached:persisted', 'recovery:upsert']) - }) - - it('replaces a live session whose detach write keeps failing', async () => { - const targetId = 'ssh-consumer-identity-detach-write-failure' - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - await handlers.get('ssh:connect')!(null, { targetId }) - const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId - expect(claimedId).toEqual(expect.any(String)) - - // Why a permanent reject, not once: it proves the failed session is gone rather than merely - // retried — a second connect that still holds it would fail on the same write again. - mockStore.markSshRemotePtyLeasesAsync.mockImplementation((_id: string, state: string) => - state === 'detached' - ? Promise.reject(new Error('lease write failed')) - : Promise.resolve(undefined) - ) - await expect(handlers.get('ssh:connect')!(null, { targetId })).rejects.toThrow( - 'lease write failed' - ) - - mockConnectionManager.connect.mockClear() - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - // Why: the abandoned session still released its identity synchronously, so the replacement - // reclaims the owner instead of minting a new one. - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) - }) - - it('resumes the remembered owner lease after a failed establish', async () => { - const targetId = 'ssh-consumer-identity-establish-failure' - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - // Why: fail the first request after the consumer session opens, so establish() rejects with an - // owner lease already remembered — the state a retry must be able to resume from. - const openClientResponse = await mockMux.request('pty.openClient') - mockMux.request.mockImplementationOnce(() => Promise.resolve(openClientResponse)) - mockMux.request.mockImplementationOnce(() => - Promise.reject(new Error('relay handshake aborted')) - ) - mockStore.markSshRemotePtyLeasesAsync.mockRejectedValueOnce( - new Error('lease persistence failed') - ) - - try { - await expect(handlers.get('ssh:connect')!(null, { targetId })).rejects.toThrow( - 'relay handshake aborted' - ) - await vi.waitFor(() => expect(warn).toHaveBeenCalled()) - const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId - expect(claimedId).toEqual(expect.any(String)) - - mockMux.request.mockClear() - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) - expect(mockMux.request).toHaveBeenCalledWith( - 'pty.openClient', - expect.objectContaining({ - clientInstanceId: claimedId, - resume: { ownerGeneration: 1, ownerLease: 'ipc-test-owner' } - }), - expect.anything() - ) - } finally { - warn.mockRestore() - } - }) - }) -}) diff --git a/src/main/ipc/ssh-relay-lost-backoff.ts b/src/main/ipc/ssh-relay-lost-backoff.ts deleted file mode 100644 index 0b92e45605a3..000000000000 --- a/src/main/ipc/ssh-relay-lost-backoff.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { SshConnectionStatus } from '../../shared/ssh-types' - -// Why: without backoff, a relay channel that keeps dying reconnects as fast as the network allows, hammering local + remote sshd; track attempts and back off to end the loop recoverably. -export type RelayLostBackoffState = { - attempts: number - reconnectTimer: ReturnType | null - stabilizedTimer: ReturnType | null -} -export const relayLostBackoff = new Map() -export const RELAY_LOST_MAX_ATTEMPTS = 6 -export const RELAY_LOST_BASE_DELAY_MS = 500 -export const RELAY_LOST_MAX_DELAY_MS = 15_000 -// Why: a reconnect whose mux dies within this window was a flap, not a recovery — don't reset the attempt counter. 5s covers provider re-registration + PTY reattach. -export const RELAY_LOST_STABILIZED_MS = 5_000 -// Why: transport states the SSH ladder never leaves on its own — waiting for a relay redeploy past one of these is an unbounded loop. -export const TRANSPORT_TERMINAL_STATUSES = new Set([ - 'disconnected', - 'auth-failed', - 'reconnection-failed', - 'error' -]) - -export function clearRelayLostBackoff(targetId: string): void { - const state = relayLostBackoff.get(targetId) - if (state?.reconnectTimer) { - clearTimeout(state.reconnectTimer) - } - if (state?.stabilizedTimer) { - clearTimeout(state.stabilizedTimer) - } - relayLostBackoff.delete(targetId) -} diff --git a/src/main/ipc/ssh-relay-reset-resume.test.ts b/src/main/ipc/ssh-relay-reset-resume.test.ts deleted file mode 100644 index b93d9f430991..000000000000 --- a/src/main/ipc/ssh-relay-reset-resume.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import { - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, - type SshConnectionState, - type SshTarget -} from '../../shared/ssh-types' -import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection } from './pty' -import { quitTeardownStartGate } from '../quit-teardown-start-gate' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { - powerMonitorOnMock, - mockSshStore, - mockConnectionManager, - mockForceStopRelayForTarget, - mockMux, - mockPortForwardManager -} = mocks - -describe('SSH IPC handlers', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(harness.reset) - - it('ssh:resetRelay force-stops the remote relay and expires tracked leases', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }, - { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' }, - { targetId: 'ssh-1', ptyId: 'pty-superseded', state: 'expired', supersededBy: 'pty-9' }, - { targetId: 'ssh-1', ptyId: 'pty-recycled', state: 'expired', relayIdRecycled: true } - ]) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-2']) - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') - // Every already-`expired` lease is skipped on its raw state, marked or not: reset retires the - // routes this force-stop invalidated, and an expired lease has none left to retire. It is also - // never upgraded to `terminated` — a killed relay makes its PTYs unreachable, not proven dead. - for (const ptyId of ['pty-expired', 'pty-superseded', 'pty-recycled']) { - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', ptyId, 'expired') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', ptyId, 'terminated') - } - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - // A force-stop that threw observed nothing about the remote shells, so expiring their leases - // would record a verdict Orca never obtained (docs/reference/ssh-execution-boundary.md). - it('ssh:resetRelay keeps leases alive when the force-stop never reported a result', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }, - { targetId: 'ssh-1', ptyId: 'pty-2', state: 'attached' } - ]) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockForceStopRelayForTarget.mockRejectedValueOnce(new Error('channel closed')) - - await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'channel closed' - ) - - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() - // The local handles are still stale — only the host-side verdict is withheld. - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-1') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:resetRelay clears scoped live PTYs while expiring raw leases', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-lease', state: 'detached' } - ]) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:ssh-1@@pty-live']) - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'expired') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('retires the captured session when reset forward teardown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( - new Error('forward teardown failed') - ) - - await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'forward teardown failed' - ) - expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') - expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledTimes(1) - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:resetRelay waits for an in-flight connect before tearing down the session', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - let resolveConnect!: (value: unknown) => void - const connectResult = new Promise((resolve) => { - resolveConnect = resolve - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockReturnValue(connectResult) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const connectPromise = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - const resetPromise = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - await Promise.resolve() - - expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() - expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() - - resolveConnect(conn) - await connectPromise - await resetPromise - - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:resetRelay does not open a transport when shutdown starts while it waits for a connect', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let failConnect!: (error: unknown) => void - const connectResult = new Promise((_resolve, reject) => { - failConnect = reject - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockReturnValue(connectResult) - // Why undefined: reset must fall through to opening its own transport, which is the call under test. - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const connectPromise = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - // Why admitted first: the gate latches only after reset is already parked behind the connect, so - // the entry fence cannot be what stops it. - const resetPromise = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - await Promise.resolve() - - quitTeardownStartGate.tryStart({ preventDefault() {} }) - failConnect(new Error('transport lost')) - await expect(connectPromise).rejects.toThrow('transport lost') - - await expect(resetPromise).rejects.toThrow('closed for app shutdown') - // Why once: the resumed reset must not open a second transport that would outlive the drain. - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() - }) - - it('ssh:connect waits for an in-flight reset before starting a new connection', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const resetConn = {} - const connectConn = {} - let resolveForceStop!: () => void - const forceStopResult = new Promise((resolve) => { - resolveForceStop = resolve - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(resetConn) - mockConnectionManager.connect.mockResolvedValue(connectConn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockForceStopRelayForTarget.mockReturnValue(forceStopResult) - - const resetPromise = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - const connectPromise = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - - await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1)) - await Promise.resolve() - - expect(mockConnectionManager.connect).not.toHaveBeenCalled() - - resolveForceStop() - await resetPromise - await connectPromise - - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) - }) - - it('ssh:resetRelay reuses duplicate in-flight resets for the same target', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - let resolveForceStop!: () => void - let activeForceStops = 0 - let maxConcurrentForceStops = 0 - const forceStopResult = new Promise((resolve) => { - resolveForceStop = resolve - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockForceStopRelayForTarget.mockImplementation(async () => { - activeForceStops += 1 - maxConcurrentForceStops = Math.max(maxConcurrentForceStops, activeForceStops) - await forceStopResult - activeForceStops -= 1 - }) - - const firstReset = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - const secondReset = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - - expect(secondReset).toBe(firstReset) - await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1)) - - resolveForceStop() - await Promise.all([firstReset, secondReset]) - - expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1) - expect(maxConcurrentForceStops).toBe(1) - expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(1) - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('keeps removal behind an in-flight relay reset', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveForceStop!: () => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue({}) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockForceStopRelayForTarget.mockReturnValueOnce( - new Promise((resolve) => { - resolveForceStop = resolve - }) - ) - - const reset = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledOnce()) - const removal = handlers.get('ssh:removeTarget')!(null, { - id: 'ssh-1' - }) as Promise - await Promise.resolve() - - expect(mockSshStore.removeTarget).not.toHaveBeenCalled() - resolveForceStop() - await reset - await removal - - expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(2) - expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') - }) - - it('reconnects on system resume when the relay liveness probe fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockConnectionManager.reconnect.mockImplementation(async (targetId: string) => { - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (id: string, state: SshConnectionState) => void - } - callbacks.onStateChange(targetId, { - targetId, - status: 'reconnecting', - error: null, - reconnectAttempt: 1 - }) - callbacks.onStateChange(targetId, { - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - }) - mockMux.probeLiveness.mockResolvedValue(false) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] - expect(resumeListener).toBeTypeOf('function') - - resumeListener() - - await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')) - // Why: a failed first probe gets one retry before teardown (slow post-wake network). - expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toMatchObject({ - connectionGeneration: 2 - }) - }) - - it('skips reconnect on system resume when the relay link is still alive', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockMux.probeLiveness.mockResolvedValue(true) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] - expect(resumeListener).toBeTypeOf('function') - - resumeListener() - - await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1)) - // Let the async resume handler settle before asserting no teardown happened. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() - }) - - it('does not reconnect after resume when the target was disconnected during the probe', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockMux.probeLiveness.mockResolvedValue(false) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] - expect(resumeListener).toBeTypeOf('function') - - resumeListener() - // Why: the probe window is seconds long; a user disconnect during it must - // win — reconnecting afterwards would resurrect the torn-down target. - mockConnectionManager.getConnection.mockReturnValue(undefined) - - await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() - }) - - it('extends active relay grace while the system is suspending', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockMux.notify.mockClear() - - const suspendListener = powerMonitorOnMock.mock.calls.find( - ([event]) => event === 'suspend' - )?.[1] - expect(suspendListener).toBeTypeOf('function') - - suspendListener() - - expect(mockMux.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 0 - }) - }) - - it('ssh:resetRelay expires active-session leases instead of marking them terminated', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockStore.markSshRemotePtyLeasesAsync.mockClear() - mockStore.markSshRemotePtyLease.mockClear() - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' } - ]) - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalledWith('ssh-1', 'terminated') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - }) -}) diff --git a/src/main/ipc/ssh-relay-session-callbacks.ts b/src/main/ipc/ssh-relay-session-callbacks.ts deleted file mode 100644 index 976af9923982..000000000000 --- a/src/main/ipc/ssh-relay-session-callbacks.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { activeSessions } from './ssh-active-relay-sessions' -import { testingTargets } from './ssh-connect-attempt-registry' -import { relayGracePeriodForTarget } from './ssh-connection-state-callbacks' -import { - connectionManager, - currentRuntime, - getCurrentMainWindow, - persistedStore, - portForwardManager -} from './ssh-ipc-context' -import { restorePortForwards } from './ssh-port-forward-persistence' -import { - clearRelayLostBackoff, - RELAY_LOST_BASE_DELAY_MS, - RELAY_LOST_MAX_ATTEMPTS, - RELAY_LOST_MAX_DELAY_MS, - RELAY_LOST_STABILIZED_MS, - relayLostBackoff, - TRANSPORT_TERMINAL_STATUSES -} from './ssh-relay-lost-backoff' -import { - broadcastDetectedPortsFromCurrentWindow, - broadcastSshState, - clearRelayStateOverride, - connectionSupportsFolderDownload, - publishRelayOverride -} from './ssh-renderer-broadcast' - -export function configureRelaySessionCallbacks(session: SshRelaySession): void { - session.setOnTerminalRelayError((tid, err) => { - clearRelayLostBackoff(tid) - if (activeSessions.get(tid)?.getState() !== 'deploying') { - rotateSshProviderAuthority(tid) - } - console.warn( - `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` - ) - publishRelayOverride(getCurrentMainWindow, tid, 'error', err.message, 0) - }) - - session.setOnRelayLost((tid) => { - const s = activeSessions.get(tid) - if (!s) { - return - } - const c = connectionManager?.getConnection(tid) - if (!c) { - return - } - const t = getSshTargetRegistryStore()?.getTarget(tid) - - // Why: bounded exponential backoff — without it, a remote bug that closes every fresh --connect channel becomes an infinite relay-deploy loop. - const state = relayLostBackoff.get(tid) ?? { - attempts: 0, - reconnectTimer: null, - stabilizedTimer: null - } - if (state.stabilizedTimer) { - clearTimeout(state.stabilizedTimer) - state.stabilizedTimer = null - } - if (state.reconnectTimer) { - return - } - rotateSshProviderAuthority(tid) - - // Why: re-deploying the relay rides the SSH transport, so while the transport is itself down no attempt - // can succeed. Waiting at the max delay without consuming the budget keeps a flapping host off the - // manual-reconnect banner, which would tell the user to act on a link that is still auto-recovering. - const transportStatus = connectionManager?.getState(tid)?.status - const transportConnected = transportStatus === 'connected' - if (transportConnected && state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { - console.warn( - `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` - ) - relayLostBackoff.delete(tid) - // Why: surface the failure — a live SSH connection with a dead relay is otherwise invisible (typing in remote terminals just stops working). - publishRelayOverride( - getCurrentMainWindow, - tid, - 'error', - 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - 0 - ) - return - } - - const scheduleRelayRedeploy = (delay: number, attemptCharged: boolean): void => { - state.reconnectTimer = setTimeout(() => { - state.reconnectTimer = null - relayLostBackoff.set(tid, state) - const liveConn = connectionManager?.getConnection(tid) - if (!liveConn || !activeSessions.has(tid)) { - clearRelayLostBackoff(tid) - return - } - const status = connectionManager?.getState(tid)?.status - if (status === 'connected') { - if (!attemptCharged) { - // Why: waiting is free, but the deploy it defers is real — charge it here so a transport that - // flaps back to 'connected' can't redeploy forever on an uncharged budget. - state.attempts += 1 - } - void s.reconnect(liveConn, relayGracePeriodForTarget(t)) - return - } - if (status === undefined || TRANSPORT_TERMINAL_STATUSES.has(status)) { - // Why: the transport gave up for good; its own state is what the user acts on, so stop waiting for a redeploy that can never run. - clearRelayLostBackoff(tid) - return - } - // Why: still mid-transition — re-arm at the max delay without consuming an attempt. It ends once - // the transport settles: 'connected' redeploys, a terminal status or a dropped session clears above. - scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) - }, delay) - relayLostBackoff.set(tid, state) - } - - if (!transportConnected) { - publishRelayOverride( - getCurrentMainWindow, - tid, - 'reconnecting', - 'Relay channel lost. Reconnecting...', - state.attempts - ) - scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) - console.warn( - `[ssh] Relay channel for ${tid} lost while the SSH transport is ${transportStatus ?? 'unknown'}; waiting ${RELAY_LOST_MAX_DELAY_MS}ms without consuming an attempt` - ) - return - } - - const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS) - state.attempts += 1 - publishRelayOverride( - getCurrentMainWindow, - tid, - 'reconnecting', - 'Relay channel lost. Reconnecting...', - state.attempts - ) - scheduleRelayRedeploy(delay, true) - console.warn( - `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` - ) - }) - - // Why: fires after both establish() and reconnect() reach 'ready'; re-create persisted port forwards so they survive restarts and blips. - session.setOnReady((tid) => { - const state = relayLostBackoff.get(tid) - if (state) { - if (state.stabilizedTimer) { - clearTimeout(state.stabilizedTimer) - } - // Why: stabilization counts post-ready uptime; slow deploy time before `ready` doesn't prove the new relay survived real work. - state.stabilizedTimer = setTimeout(() => { - const current = relayLostBackoff.get(tid) - if (current === state && !current.reconnectTimer) { - relayLostBackoff.delete(tid) - } - }, RELAY_LOST_STABILIZED_MS) - relayLostBackoff.set(tid, state) - } - clearRelayStateOverride(tid) - if (!testingTargets.has(tid)) { - broadcastSshState(getCurrentMainWindow, tid, { - targetId: tid, - status: 'connected', - error: null, - reconnectAttempt: 0, - supportsFolderDownload: connectionSupportsFolderDownload(tid) - }) - } - currentRuntime?.notifySshRelayReady?.(tid) - void restorePortForwards(tid, getCurrentMainWindow) - }) -} - -export function refreshActiveRelaySessions(): void { - if (!persistedStore || !portForwardManager) { - return - } - for (const session of activeSessions.values()) { - session.refreshEnvironment( - getCurrentMainWindow, - persistedStore, - portForwardManager, - currentRuntime, - broadcastDetectedPortsFromCurrentWindow - ) - configureRelaySessionCallbacks(session) - } -} diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 00a58f246dfc..4d2f9f613b25 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -1,11 +1,6 @@ import type { BrowserWindow } from 'electron' import type { SshPortForwardManager } from '../ssh/ssh-port-forward' -import type { - DetectedPort, - EnrichedDetectedPort, - SshConnectionStatus, - SshConnectionState -} from '../../shared/ssh-types' +import type { DetectedPort, EnrichedDetectedPort, SshConnectionState } from '../../shared/ssh-types' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { enrichSshDetectedPorts, @@ -14,11 +9,8 @@ import { } from '../ports/ssh-advertised-url-enrichment' import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' -import { isSshRelayOnHostNodeRuntime } from '../ssh/ssh-host-node-runtime-mode' import { clearSshHostServerStatus, getSshHostServerStatus } from '../ssh/ssh-host-server-status' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager, currentRuntime, @@ -27,8 +19,6 @@ import { portForwardManager } from './ssh-ipc-context' -export const relayStateOverrides = new Map() - export function broadcastSshState( getMainWindow: () => BrowserWindow | null, targetId: string, @@ -50,46 +40,17 @@ export function broadcastSshState( } function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { - const session = activeSessions.get(targetId) - const remotePlatform = - session?.getHostPlatform()?.os ?? session?.getPlainSshSession()?.remotePlatform const authority = getSshProviderAuthority(targetId) - const plainSsh = state.status === 'connected' ? getSshPlainSshMode(targetId) : undefined const managedServer = state.managedServer ?? getSshHostServerStatus(targetId) - // Why the managed check: a host that moved to its managed server no longer runs that relay. - const hostNodeRuntime = - state.status === 'connected' && - !plainSsh && - managedServer?.kind !== 'managed' && - isSshRelayOnHostNodeRuntime(targetId) return { ...state, targetId, providerEpoch: authority.providerEpoch, connectionGeneration: authority.connectionGeneration, - ...(remotePlatform ? { remotePlatform } : {}), - ...(plainSsh ? { plainSsh } : {}), - ...(hostNodeRuntime ? { hostNodeRuntime } : {}), ...(managedServer ? { managedServer } : {}) } } -export function publishRelayOverride( - getMainWindow: () => BrowserWindow | null, - targetId: string, - status: SshConnectionStatus, - error: string | null, - reconnectAttempt: number -): void { - const state = withSshRemotePlatform(targetId, { targetId, status, error, reconnectAttempt }) - relayStateOverrides.set(targetId, state) - broadcastSshState(getMainWindow, targetId, state) -} - -export function clearRelayStateOverride(targetId: string): void { - relayStateOverrides.delete(targetId) -} - export function connectionSupportsFolderDownload(targetId: string): boolean { // Why: connections without an explicit transport are ssh2-shaped; only a confirmed system-SSH transport lacks the SFTP-only capability. return connectionManager?.getConnection(targetId)?.usesSystemSshTransport?.() !== true @@ -102,19 +63,14 @@ export function connectionSupportsFolderDownload(targetId: string): boolean { */ export function clearPublishedManagedServer(targetId: string): void { clearSshHostServerStatus(targetId) - const override = relayStateOverrides.get(targetId) - if (override?.managedServer) { - const { managedServer: _removed, ...rest } = override - relayStateOverrides.set(targetId, rest) - } - const state = relayStateOverrides.get(targetId) ?? connectionManager?.getState(targetId) + const state = connectionManager?.getState(targetId) if (state) { broadcastSshState(getCurrentMainWindow, targetId, state) } } export function getPublicSshState(targetId: string): SshConnectionState | undefined { - const state = relayStateOverrides.get(targetId) ?? connectionManager!.getState(targetId) + const state = connectionManager!.getState(targetId) return state ? withSshRemotePlatform(targetId, state) : undefined } diff --git a/src/main/ipc/ssh-session-teardown.ts b/src/main/ipc/ssh-session-teardown.ts index bd8dbbe0e9d7..10b40f021967 100644 --- a/src/main/ipc/ssh-session-teardown.ts +++ b/src/main/ipc/ssh-session-teardown.ts @@ -1,28 +1,20 @@ import type { DirectSshAuthority } from '../../shared/ssh-types' -import type { SshConnection } from '../ssh/ssh-connection' -import type { SshRelaySession } from '../ssh/ssh-relay-session' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { clearSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' import { isSshConnectionSolelyOwnedBy } from '../ssh/ssh-connection-attribution' -import { activeSessions } from './ssh-active-relay-sessions' import { connectInFlight, invalidateConnectAttempt, isCurrentConnectAttempt } from './ssh-connect-attempt-registry' import { connectionManager, persistedStore, portForwardManager } from './ssh-ipc-context' -import { clearRelayLostBackoff } from './ssh-relay-lost-backoff' -import { clearRelayStateOverride } from './ssh-renderer-broadcast' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' import { errorMessage } from '../../shared/error-message' export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) clearSshHostServerStatus(targetId) - await runTargetLifecycle(targetId, () => - teardownSshTargetTransport(targetId, (session) => session.detachAndPersist()) - ) + await runTargetLifecycle(targetId, () => teardownSshTargetTransport(targetId)) } export async function removeRegisteredSshTarget(targetId: string): Promise { @@ -32,11 +24,9 @@ export async function removeRegisteredSshTarget(targetId: string): Promise } invalidateConnectAttempt(targetId) clearSshHostServerStatus(targetId) - recordSshRelayRuntimeStep(targetId, false) await runTargetLifecycle(targetId, async () => { try { - // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. - await teardownSshTargetTransport(targetId, (session) => session.disposeAndPersist()) + await teardownSshTargetTransport(targetId) } catch (err) { // Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases. console.warn(`[ssh] Failed to disconnect removed target ${targetId}: ${errorMessage(err)}`) @@ -66,100 +56,18 @@ export async function removeRegisteredSshTarget(targetId: string): Promise }) } -export async function teardownSshTargetTransport( - targetId: string, - teardown: (session: SshRelaySession) => void | Promise -): Promise { - // Why: start the transport disconnect before session teardown; the async wrapper turns a sync throw into a rejection. - const transportDisconnect = (async () => connectionManager?.disconnect(targetId))() - const [teardownResult, disconnectResult] = await Promise.allSettled([ - teardownActiveSshSession(targetId, teardown), - transportDisconnect +export async function teardownSshTargetTransport(targetId: string): Promise { + // Why first: local listeners must be released before disconnect completes, else an immediate + // reconnect hits EADDRINUSE. + const [forwards, disconnect] = await Promise.allSettled([ + portForwardManager?.removeAllForwards(targetId), + (async () => connectionManager?.disconnect(targetId))() ]) - if (teardownResult.status === 'rejected') { - throw teardownResult.reason - } - if (disconnectResult.status === 'rejected') { - throw disconnectResult.reason - } -} - -export async function teardownActiveSshSession( - targetId: string, - teardown: (session: SshRelaySession) => void | Promise -): Promise { - const session = activeSessions.get(targetId) - if (!session) { - return + if (forwards.status === 'rejected') { + throw forwards.reason } - let teardownError: { error: unknown } | null = null - try { - // Why: await port teardown so local listeners are released before disconnect/remove completes, else an immediate reconnect hits EADDRINUSE. - await portForwardManager?.removeAllForwards(targetId) - } catch (error) { - teardownError = { error } - } - try { - await teardown(session) - } catch (error) { - teardownError ??= { error } - } - if (activeSessions.get(targetId) === session) { - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) - } - if (teardownError) { - throw teardownError.error - } -} - -// Why: a dropped session must detach, not just leave activeSessions — detach releases the SSH PTY -// consumer identity so the next connect reclaims its owner lease instead of minting a new one. -// Why awaited, and why the map entry outlives the await: a retry can start the moment this returns, -// so it must either find the session (and await this same latched teardown at the existing-session -// path) or find nothing because the 'detached' lease write is already durable. Deleting first lets a -// fast reconnect mark leases 'attached' and then have this session's late 'detached' write clobber it. -export async function abandonFailedSshSession( - targetId: string, - session: SshRelaySession -): Promise { - // Why: detachAndPersist transitions recovery ownership synchronously; only durability is awaited. - try { - await session.detachAndPersist() - } catch (error) { - // Why: a teardown throw must not mask the connect error the caller is about to rethrow. - console.warn(`[ssh] Failed to detach abandoned session for ${targetId}: ${errorMessage(error)}`) - } - if (activeSessions.get(targetId) === session) { - activeSessions.delete(targetId) - } -} - -// Why: a connect cancelled after its transport already opened still owns that transport and its -// unpublished session — nothing else will reach them, so it has to close them itself. Why only the -// connection it minted, and by identity: disconnecting by target id (or closing a transport it merely -// reused) would tear down the replacement connect's live transport. -export async function abandonCancelledConnectAttempt( - targetId: string, - session: SshRelaySession, - mintedConnection: SshConnection | null -): Promise { - // Why the identity guard: every path that removes a session from activeSessions detaches it first, - // so re-detaching a superseded session would only clobber the replacement's lease state. - if (activeSessions.get(targetId) === session) { - await abandonFailedSshSession(targetId, session) - } - if (!mintedConnection) { - return - } - try { - await connectionManager!.disconnectConnection(targetId, mintedConnection) - } catch (error) { - // Why: the caller is about to throw the cancellation; a teardown throw must not replace it. - console.warn( - `[ssh] Failed to disconnect cancelled connect transport for ${targetId}: ${errorMessage(error)}` - ) + if (disconnect.status === 'rejected') { + throw disconnect.reason } } diff --git a/src/main/ipc/ssh-shutdown-drain.ts b/src/main/ipc/ssh-shutdown-drain.ts index 11e9b75c71d5..69e35de07a12 100644 --- a/src/main/ipc/ssh-shutdown-drain.ts +++ b/src/main/ipc/ssh-shutdown-drain.ts @@ -1,12 +1,9 @@ -import { activeSessions } from './ssh-active-relay-sessions' import { connectInFlight, invalidateConnectAttempt, - resetRelayInFlight, testConnectionProbes } from './ssh-connect-attempt-registry' import { connectionManager } from './ssh-ipc-context' -import { teardownActiveSshSession } from './ssh-session-teardown' // Why one budget for the whole sequence rather than one per phase: an invalidated connect only // observes its cancellation at the next checkpoint, and one blocked in the transport handshake can @@ -61,14 +58,8 @@ async function settleTasksWithinMs( return { timedOut: [...pending], errors } } -function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { +function sshShutdownTasks(): SshShutdownTask[] { return [ - ...targetIds - .filter((targetId) => activeSessions.has(targetId)) - .map((targetId) => ({ - targetId, - promise: teardownActiveSshSession(targetId, (session) => session.detachAndPersist()) - })), { targetId: '*transports', promise: connectionManager?.disconnectAll() ?? Promise.resolve() @@ -76,14 +67,10 @@ function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { ] } -async function drainSshShutdown( - targetIds: readonly string[], - inFlight: readonly SshShutdownTask[], - detachErrors: readonly unknown[] = [] -): Promise { +async function drainSshShutdown(inFlight: readonly SshShutdownTask[]): Promise { const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS const unfinished: SshShutdownUnfinished[] = [] - const errors: unknown[] = [...detachErrors] + const errors: unknown[] = [] const runPhase = async ( phase: SshShutdownPhase, tasks: readonly SshShutdownTask[] @@ -99,12 +86,11 @@ async function drainSshShutdown( return settled.timedOut.length === 0 } - await runPhase('drain', sshShutdownTasks(targetIds)) - // Why a second drain after the join: a connect paused in old-session teardown still publishes its - // replacement session and opens a transport before it reaches the cancellation checkpoint, so the - // first drain can miss both. + await runPhase('drain', sshShutdownTasks()) + // Why a second drain after the join: a connect can open a transport before it reaches the + // cancellation checkpoint, so the first drain can miss it. if (await runPhase('in-flight-join', inFlight)) { - await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()])) + await runPhase('final-drain', sshShutdownTasks()) } if (errors.length > 0 || unfinished.length > 0) { @@ -135,27 +121,11 @@ export function beginSshShutdown(): Promise { targetId, promise: attempt.promise })), - ...[...resetRelayInFlight.entries()].map(([targetId, promise]) => ({ targetId, promise })), ...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise })) ] for (const targetId of Array.from(connectInFlight.keys())) { invalidateConnectAttempt(targetId) } - const targetIds = [...activeSessions.keys()] - // Why before any await: this is the whole point of the split. Each session marks its recovery lease - // detached in memory now, and the final flush persists it — the remote PTYs keep running. - const detachErrors: unknown[] = [] - for (const session of activeSessions.values()) { - // Why per-session: this runs synchronously inside a non-async will-quit listener, so one throw - // (teardownProviders -> webContents.send on a destroyed renderer, routine on quit) would escape - // it and skip every later session, the drain assignment, and the store flush that persists all - // of this. Collect and keep going; the drain reports them. - try { - session.beginShutdownDetach() - } catch (error) { - detachErrors.push(error) - } - } - sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors) + sshShutdownDrain = drainSshShutdown(inFlight) return sshShutdownDrain } diff --git a/src/main/ipc/ssh-state-broadcast-fanout.test.ts b/src/main/ipc/ssh-state-broadcast-fanout.test.ts index 5d3ef9c4a65d..7faafee3c79b 100644 --- a/src/main/ipc/ssh-state-broadcast-fanout.test.ts +++ b/src/main/ipc/ssh-state-broadcast-fanout.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -25,157 +23,16 @@ vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) import { registerSshHandlers } from './ssh' import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { - mockSshStore, - mockConnectionManager, - mockAcceptSshPtyOutputData, - mockAcceptSshPtyOutputExit, - mockPtyProvider -} = mocks +const { mockSshStore, mockConnectionManager } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { ipcTestSource, handlers, mockStore, mockWindow } = harness + const { mockStore, mockWindow } = harness beforeEach(harness.reset) - it('forwards remote PTY events through the output intake authority', async () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as SshPtyDataCallback | undefined - const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as - | ((payload: { - id: string - code: number - providerGeneration: number - ptyIncarnation: string - }) => void) - | undefined - - onData?.({ - id: 'remote-pty', - data: 'hello', - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty', - source: ipcTestSource - }) - onExit?.({ - id: 'remote-pty', - code: 7, - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty' - }) - - expect(mockAcceptSshPtyOutputData).toHaveBeenCalledWith({ - id: 'remote-pty', - data: 'hello', - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty', - rawLength: 'hello'.length, - transformed: false, - source: ipcTestSource - }) - expect(mockAcceptSshPtyOutputExit).toHaveBeenCalledWith({ - id: 'remote-pty', - code: 7, - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty' - }) - expect(runtime.onPtyData).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - }) - - it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn(), - notifySshStateChanged: vi.fn(), - notifySshRelayReady: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - // Why: paired remote clients only learn SSH state through this hook — - // without it their reconnect overlays never clear (STA-1468). - expect(runtime.notifySshStateChanged).toHaveBeenCalledWith( - 'ssh-1', - expect.objectContaining({ targetId: 'ssh-1', status: 'connected' }) - ) - expect(runtime.notifySshRelayReady).toHaveBeenCalledWith('ssh-1') - }) - - it('keeps runtime-owned SSH state off the renderer while invalidating runtime scans', async () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn(), - invalidateSshWorktreeScanCache: vi.fn(), - notifySshStateChanged: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - mockSshStore.getTarget.mockReturnValue({ - id: 'runtime-ssh-1', - label: 'Runtime host', - host: 'example.com', - port: 22, - username: 'deploy' - } satisfies SshTarget) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'runtime-ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'runtime-ssh-1' }) - - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'ssh:state-changed', - expect.anything() - ) - expect(runtime.invalidateSshWorktreeScanCache).toHaveBeenCalledWith('runtime-ssh-1') - expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() - }) - it('keeps claimed managed-orcad SSH state off clients under the original target id', () => { const runtime = { onPtyData: vi.fn(), diff --git a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts index 630615f001cb..15d2c53fd918 100644 --- a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts +++ b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => { state, addTarget: vi.fn(), updateTarget: vi.fn(), + setMutations: vi.fn(), closeTunnel: vi.fn(async () => {}), disconnect: vi.fn(async () => {}) } @@ -21,7 +22,8 @@ vi.mock('../ssh/ssh-target-registry', () => ({ addTarget: mocks.addTarget, updateTarget: mocks.updateTarget, lastRepoReadoptions: [] - }) + }), + setSshTargetMutations: mocks.setMutations })) vi.mock('./ssh-session-teardown', () => ({ removeRegisteredSshTarget: mocks.remove })) vi.mock('../ssh/orcad-managed-tunnel', () => ({ closeOrcadManagedTunnel: mocks.closeTunnel })) @@ -92,6 +94,14 @@ describe('SSH target CRUD against managed orcad targets', () => { expect(mocks.remove).not.toHaveBeenCalled() }) + it('gives paired clients the same rules: a managed host is refused there too', async () => { + const mutations = mocks.setMutations.mock.calls.at(-1)?.[0] + await expect(mutations.remove('ssh-1')).rejects.toThrow('Settings › Managed servers') + mutations.update('ssh-1', { host: 'elsewhere', generation: 9 }) + expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { host: 'elsewhere' }) + expect(mocks.remove).not.toHaveBeenCalled() + }) + it('never lets the renderer write a provisioning intent', () => { mocks.state.target = target handler('ssh:updateTarget')(null, { diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 010bc6635907..2a842b943d7e 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -2,6 +2,8 @@ import { ipcMain } from 'electron' import type { SshConfigHostListArgs, SshRepoReadoption, + SshTarget, + SshTargetAddResult, SshTargetCreateInput, SshTargetUpdateInput } from '../../shared/ssh-types' @@ -12,7 +14,7 @@ import { import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' import { closeOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import { getSshTargetRegistryStore, setSshTargetMutations } from '../ssh/ssh-target-registry' import { allowsDirectSshRelay, isManagedOrcadSshTarget, @@ -22,6 +24,7 @@ import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' import { fingerprintRuntimeSshTarget } from '../ssh/runtime-ssh-access' import { removeRegisteredSshTarget } from './ssh-session-teardown' +import { notifyReposChanged } from './repos/repos-changed-notification' // Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them. function takeRepoReadoptions(): SshRepoReadoption[] { @@ -36,10 +39,8 @@ function takeRepoReadoptions(): SshRepoReadoption[] { )) { rotateSshProviderAuthority(targetId) } - const win = getCurrentMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('repos:changed') - } + // Why: paired clients refetch repos only on this event, and they may have made the change. + notifyReposChanged(getCurrentMainWindow()) return repoReadoptions } @@ -97,7 +98,49 @@ export function assertNotManagedServerHost(targetId: string): void { } } +function addSshTarget(input: SshTargetCreateInput): SshTargetAddResult { + const target = getSshTargetRegistryStore()!.addTarget(omitRendererSshTargetGeneration(input)) + // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. + const repoReadoptions = takeRepoReadoptions() + return { target, repoReadoptions } +} + +function updateSshTarget(id: string, updates: SshTargetUpdateInput): SshTarget | null { + assertNotRuntimeOwned(id, 'edited') + const before = getSshTargetRegistryStore()!.getTarget(id) + // The fence and generation are stripped, so a managed host keeps its server binding. + const updated = getSshTargetRegistryStore()!.updateTarget( + id, + omitRendererSshTargetGeneration(updates) + ) + const environmentId = getManagedOrcadFenceEnvironmentId(updated ?? undefined) + if (environmentId && updated) { + // Why: the tunnel and the SSH transport under it were built from the old fields, so both go + // and the next use dials the edited target. Only a host reached through its managed server: + // one an older build changed runs on the relay directly, whose session owns the transport. + const redial = + !allowsDirectSshRelay(updated) && + (!before || fingerprintRuntimeSshTarget(before) !== fingerprintRuntimeSshTarget(updated)) + void runTargetLifecycle(id, async () => { + await closeOrcadManagedTunnel(environmentId) + if (redial) { + await connectionManager?.disconnect(id) + } + }).catch(() => undefined) + } + return updated +} + +async function removeSshTarget(id: string): Promise { + assertNotRuntimeOwned(id, 'removed') + assertNotManagedServerHost(id) + await removeRegisteredSshTarget(id) +} + export function registerSshTargetCrudHandlers(): void { + // Why: paired clients manage this host's targets over RPC with the same rules as SSH settings. + setSshTargetMutations({ add: addSshTarget, update: updateSshTarget, remove: removeSshTarget }) + ipcMain.handle('ssh:listTargets', () => { return getSshTargetRegistryStore()!.listTargets() }) @@ -106,49 +149,17 @@ export function registerSshTargetCrudHandlers(): void { return getSshTargetRegistryStore()!.listRemovedTargetLabels() }) - ipcMain.handle('ssh:addTarget', (_event, args: { target: SshTargetCreateInput }) => { - const target = getSshTargetRegistryStore()!.addTarget( - omitRendererSshTargetGeneration(args.target) - ) - // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. - const repoReadoptions = takeRepoReadoptions() - return { target, repoReadoptions } - }) + ipcMain.handle('ssh:addTarget', (_event, args: { target: SshTargetCreateInput }) => + addSshTarget(args.target) + ) ipcMain.handle( 'ssh:updateTarget', - (_event, args: { id: string; updates: SshTargetUpdateInput }) => { - assertNotRuntimeOwned(args.id, 'edited') - const before = getSshTargetRegistryStore()!.getTarget(args.id) - // The fence and generation are stripped, so a managed host keeps its server binding. - const updated = getSshTargetRegistryStore()!.updateTarget( - args.id, - omitRendererSshTargetGeneration(args.updates) - ) - const environmentId = getManagedOrcadFenceEnvironmentId(updated ?? undefined) - if (environmentId && updated) { - // Why: the tunnel and the SSH transport under it were built from the old fields, so both go - // and the next use dials the edited target. Only a host reached through its managed server: - // one an older build changed runs on the relay directly, whose session owns the transport. - const redial = - !allowsDirectSshRelay(updated) && - (!before || fingerprintRuntimeSshTarget(before) !== fingerprintRuntimeSshTarget(updated)) - void runTargetLifecycle(args.id, async () => { - await closeOrcadManagedTunnel(environmentId) - if (redial) { - await connectionManager?.disconnect(args.id) - } - }).catch(() => undefined) - } - return updated - } + (_event, args: { id: string; updates: SshTargetUpdateInput }) => + updateSshTarget(args.id, args.updates) ) - ipcMain.handle('ssh:removeTarget', async (_event, args: { id: string }) => { - assertNotRuntimeOwned(args.id, 'removed') - assertNotManagedServerHost(args.id) - await removeRegisteredSshTarget(args.id) - }) + ipcMain.handle('ssh:removeTarget', (_event, args: { id: string }) => removeSshTarget(args.id)) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { const targets = getSshTargetRegistryStore()!.importFromSshConfig(args) diff --git a/src/main/ipc/ssh-target-registry.test.ts b/src/main/ipc/ssh-target-registry.test.ts index 9aa205d7a04e..44eca1388726 100644 --- a/src/main/ipc/ssh-target-registry.test.ts +++ b/src/main/ipc/ssh-target-registry.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -26,14 +24,7 @@ vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) import type { SshTarget } from '../../shared/ssh-types' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { - mockSshStore, - mockConnectionManager, - mockMux, - mockPortForwardManager, - mockListConfigHosts, - mockResolveConfigHost -} = mocks +const { mockSshStore, mockConnectionManager, mockListConfigHosts, mockResolveConfigHost } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) @@ -52,9 +43,9 @@ describe('SSH IPC handlers', () => { expect(channels).toContain('ssh:resolveConfigHost') expect(channels).toContain('ssh:connect') expect(channels).toContain('ssh:disconnect') - expect(channels).toContain('ssh:terminateSessions') - expect(channels).toContain('ssh:moveToManagedServer') - expect(channels).toContain('ssh:resetRelay') + expect(channels).not.toContain('ssh:terminateSessions') + expect(channels).not.toContain('ssh:moveToManagedServer') + expect(channels).not.toContain('ssh:resetRelay') expect(channels).toContain('ssh:getState') expect(channels).toContain('ssh:testConnection') }) @@ -153,36 +144,6 @@ describe('SSH IPC handlers', () => { } }) - it('ssh:removeTarget tears down an active relay before deleting the target', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockClear() - mockConnectionManager.disconnect.mockClear().mockResolvedValue(undefined) - - await handlers.get('ssh:removeTarget')!(null, { id: 'ssh-1' }) - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'terminated') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') - expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') - }) - it('ssh:importConfig returns imported targets', async () => { const imported: SshTarget[] = [ { id: 'ssh-imp', label: 'staging', host: 'staging.com', port: 22, username: '' } diff --git a/src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts b/src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts deleted file mode 100644 index 4d9cbe130b82..000000000000 --- a/src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -const { listPreviousRelayPtyIds } = vi.hoisted(() => ({ - listPreviousRelayPtyIds: vi.fn(async (): Promise => null) -})) -vi.mock('../ssh/ssh-legacy-relay-routing', async (importOriginal) => ({ - ...(await importOriginal>()), - listPreviousRelayPtyIds -})) -vi.mock('../ssh/ssh-previous-relay-terminals', () => ({ - isReattachHeldByPreviousRelay: vi.fn(async () => true), - startPreviousRelayCensus: vi.fn(), - clearPreviousRelayCensus: vi.fn() -})) - -import type { SshTarget } from '../../shared/ssh-types' -import { getSshPtyProvider, getPtyIdsForConnection } from './pty' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockPtyProvider } = mocks - -describe('ssh:terminateSessions while an older relay may hold terminals', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(() => { - harness.reset() - listPreviousRelayPtyIds.mockReset().mockResolvedValue(null) - }) - - it('keeps a not-found terminal the previous relay may run and reports it unverifiable', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-held', state: 'detached' } - ]) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared IPC mock provider implements the shutdown this path calls. - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new Error('PTY "pty-held" not found')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-held', - 'terminated' - ) - // The final teardown must not bulk-mark the held lease terminated either. - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalledWith('ssh-1', 'terminated') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - }) - - // B4: a shell a respawn superseded on its tab still runs on the previous relay, leaseless. - it('stops a shell only an older relay lists, through the provider that routes it there', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockConnectionManager.connect.mockResolvedValue({}) - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared IPC mock provider implements the shutdown this path calls. - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - listPreviousRelayPtyIds.mockResolvedValue(['ssh:ssh-1@@pty2:old:1']) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 1, unverifiable: 0 }) - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty2:old:1', { - immediate: true, - keepHistory: false - }) - }) -}) diff --git a/src/main/ipc/ssh-terminate-sessions.test.ts b/src/main/ipc/ssh-terminate-sessions.test.ts deleted file mode 100644 index 0fc752831ac2..000000000000 --- a/src/main/ipc/ssh-terminate-sessions.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock( - '../ssh/ssh-previous-relay-terminals', - () => import('../ssh/ssh-previous-relay-census-test-double') -) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import { SshPtyHeldByPreviousRelayError } from '../providers/ssh-pty-errors' -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { - clearProviderPtyState, - deletePtyOwnership, - getSshPtyProvider, - getPtyIdsForConnection -} from './pty' -import { createSshIpcHarness } from './ssh-ipc-test-harness' -import { setCurrentRuntime } from './ssh-ipc-context' - -const { mockSshStore, mockConnectionManager, mockPtyProvider, mockPortForwardManager } = mocks - -describe('SSH IPC handlers', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(harness.reset) - - it('ssh:terminateSessions refuses a removal of a managed server host before ending anything', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - orcadFence: { environmentId: 'env-1' } - }) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - expect(() => - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1', forRemoval: true }) - ).toThrow('Settings › Managed servers') - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - }) - - it('ssh:terminateSessions preserves tracking when relay shutdown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - mockPtyProvider.shutdown.mockRejectedValue(new Error('mux down')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow('Failed to terminate SSH host sessions') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', 'pty-1', 'terminated') - expect(mockConnectionManager.disconnect).not.toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:terminateSessions cleans scoped live PTYs while tombstoning raw leases', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-lease', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:ssh-1@@pty-live']) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 2, unverifiable: 0 }) - - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', { - immediate: true, - keepHistory: false - }) - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease', { - immediate: true, - keepHistory: false - }) - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-live', 'terminated') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'terminated') - }) - - it('keeps reconnect behind the complete terminate-sessions lifecycle', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveShutdown!: () => void - let resolveForwardRemoval!: () => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockReturnValueOnce( - new Promise((resolve) => { - resolveShutdown = resolve - }) - ) - mockPortForwardManager.removeAllForwards.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveForwardRemoval = resolve - }) - ) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const terminate = handlers.get('ssh:terminateSessions')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockPtyProvider.shutdown).toHaveBeenCalledOnce()) - const reconnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - - resolveShutdown() - await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveForwardRemoval() - await terminate - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - - await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) - }) - - // Issue #12661: an offline sweep tears down local transport only. Reporting plain success would - // read as "the remote shells are gone" when nobody asked the host. - it('ssh:terminateSessions reports superseded leases as unverifiable without a relay', async () => { - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired', supersededBy: 'pty-2' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - // Still no forced reconnect: a newer lease won this pane, so this route died for good and must - // never block a target the user is trying to remove (#2626). - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-expired', - 'terminated' - ) - }) - - it('ssh:terminateSessions leaves a recycled relay id out of the reconnect fence', async () => { - // The host listed this id under a different PTY incarnation, so it no longer routes to this - // lease's shell — reconnecting could only aim the stop at a stranger's process. - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-recycled', state: 'expired', relayIdRecycled: true } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - // An `expired` lease carrying neither retirement mark is an orphan, not a corpse: it records only - // that this client lost its route. Answering `unverifiable` there strands a remote shell the user - // just ordered stopped, when a reconnect is exactly what would reach it. - it('ssh:terminateSessions demands a reconnect for an unmarked expired lease', async () => { - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-orphan', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow(SSH_TERMINATE_RECONNECT_REQUIRED) - - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-orphan', - 'terminated' - ) - }) - - it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => { - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 0 }) - }) - - it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-abandoned', { - immediate: true, - keepHistory: false - }) - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'ssh-1', - 'pty-abandoned', - 'terminated' - ) - }) - - it('ssh:terminateSessions stops a shell the relay runs without any lease here', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockConnectionManager.connect.mockResolvedValue({}) - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - // A CLI-created terminal: the relay lists it, but no lease or local pane knows it. - mockPtyProvider.listProcesses.mockResolvedValue([{ id: 'ssh:ssh-1@@pty-cli' }]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toMatchObject({ terminated: 1, unverifiable: 0 }) - - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-cli', { - immediate: true, - keepHistory: false - }) - }) - - it('ssh:terminateSessions tombstones an expired lease the relay reports gone', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new Error('PTY "pty-abandoned" not found')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'ssh-1', - 'pty-abandoned', - 'terminated' - ) - }) - - it('ssh:terminateSessions keeps a lease an older relay holds but no route can stop', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty2:old:1', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new SshPtyHeldByPreviousRelayError('pty2:old:1')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty2:old:1', - 'terminated' - ) - }) - - it('ssh:terminateSessions leaves leases it already proved terminated alone', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-tombstoned', state: 'terminated' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - }) - - it('ssh:terminateSessions keeps an expired lease when its shutdown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new Error('mux down')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow('Failed to terminate SSH host sessions') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-abandoned', - 'terminated' - ) - }) - - it('ssh:terminateSessions tells the runtime a stopped shell is no longer connected', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([ - 'ssh:ssh-1@@pty-lost-exit', - 'ssh:ssh-1@@pty-reported-exit' - ]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - const runtime = { - getPtyLivenessVerdict: vi.fn((ptyId: string) => - ptyId.endsWith('pty-reported-exit') ? { status: 'exited' } : null - ), - onPtyExit: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminate reads only these two runtime methods. - setCurrentRuntime(runtime as never) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 2, unverifiable: 0 }) - - // The stop sentinel, never a certified death; an exit the relay already reported stands. - expect(runtime.onPtyExit.mock.calls).toEqual([['ssh:ssh-1@@pty-lost-exit', -1]]) - setCurrentRuntime(undefined) - }) -}) diff --git a/src/main/ipc/ssh-terminate-sessions.ts b/src/main/ipc/ssh-terminate-sessions.ts deleted file mode 100644 index 40d8f85a0036..000000000000 --- a/src/main/ipc/ssh-terminate-sessions.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - sshRemotePtyLeaseAllowsReattach, - type SshTerminateSessionsResult -} from '../../shared/ssh-types' -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../shared/terminal-exit-cause' -import { isSshPtyNotFoundError, SshPtyHeldByPreviousRelayError } from '../providers/ssh-pty-errors' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { isReattachHeldByPreviousRelay } from '../ssh/ssh-previous-relay-terminals' -import { listPreviousRelayPtyIds } from '../ssh/ssh-legacy-relay-routing' -import { - clearProviderPtyState, - deletePtyOwnership, - getPtyIdsForConnection, - getSshPtyProvider -} from './pty' -import { invalidateConnectAttempt } from './ssh-connect-attempt-registry' -import { currentRuntime, persistedStore } from './ssh-ipc-context' -import { ptyIncarnationById } from './pty/provider/ownership-state' -import type { TerminalIntentionalStopKind } from '../runtime/terminal-intentional-stops' -import { teardownSshTargetTransport } from './ssh-session-teardown' -import { runTargetLifecycle } from './ssh-target-lifecycle-queue' -import { errorMessage } from '../../shared/error-message' - -export type SshTerminateSessionsOptions = { - /** Records each stop as main's own, so every viewer keeps the tab through its exit. */ - intentionalStop?: TerminalIntentionalStopKind - /** Each shell this call stopped, reported even when a later one fails. */ - onStopped?: (appPtyId: string) => void -} - -/** Stops every relay terminal on the target and closes its transport (`ssh:terminateSessions`). */ -export async function terminateSshTargetSessions( - targetId: string, - options: SshTerminateSessionsOptions = {} -): Promise { - invalidateConnectAttempt(targetId) - // Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell - // "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue. - let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 } - await runTargetLifecycle(targetId, async () => { - const provider = getSshPtyProvider(targetId) - const leases = persistedStore!.getSshRemotePtyLeases(targetId) - const ptyIdsByRelayId = new Map() - // Why: only leases the app still believes it owns may force a reconnect; a lease whose route - // died for good is swept opportunistically instead, so a target that can no longer answer - // never blocks its own removal (issue #2626, and the renderer tolerates the refusal there). - const ownedRelayIds = new Set() - const trackPtyId = (ptyId: string, owned: boolean): void => { - const relayPtyId = toRelaySshPtyId(targetId, ptyId) - if (!ptyIdsByRelayId.has(relayPtyId)) { - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(targetId, ptyId)) - } - if (owned) { - ownedRelayIds.add(relayPtyId) - } - } - for (const ptyId of getPtyIdsForConnection(targetId)) { - trackPtyId(ptyId, true) - } - for (const lease of leases) { - if (lease.state === 'terminated') { - continue - } - // Why the predicate and not `state !== 'expired'`: an `expired` lease carrying no - // retirement mark records only that reattach gave up, never that the remote shell died, so - // it is exactly the orphan the user's terminate must reach — and reaching it needs the - // relay, which is what the fence below demands. Only `supersededBy` / `relayIdRecycled` - // prove the route is dead for good, and those stay unowned. - trackPtyId(lease.ptyId, sshRemotePtyLeaseAllowsReattach(lease)) - } - // A shell a relay runs without any lease here (a CLI-created terminal, or one a respawn - // superseded on its tab) is still this host's; shutdown stops an earlier relay's held one there. - for (const ptyId of await listRelayPtyIdsToStop(targetId, provider)) { - trackPtyId(ptyId, false) - } - const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ - relayPtyId, - appPtyId - })) - - if (ownedRelayIds.size > 0 && !provider) { - throw new Error( - `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` - ) - } - const shutdownResults = provider - ? await Promise.allSettled( - ptyIds.map(({ appPtyId }) => - shutdownAs(options, appPtyId, () => - provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) - ) - ) - ) - : [] - if (!provider) { - // Nothing observed these remote shells, so their state is unknown — not "nothing to do". - outcome = { terminated: 0, unverifiable: ptyIds.length } - } - const shutdownFailures: string[] = [] - for (const [index, result] of shutdownResults.entries()) { - const { appPtyId, relayPtyId } = ptyIds[index] - if ( - result.status !== 'fulfilled' && - (result.reason instanceof SshPtyHeldByPreviousRelayError || - (await isReattachHeldByPreviousRelay(targetId, result.reason))) - ) { - // Not found here is not absence while an older build's relay may still run it (#25124). - outcome = { ...outcome, unverifiable: outcome.unverifiable + 1 } - continue - } - if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { - shutdownFailures.push(`${relayPtyId}: ${errorMessage(result.reason)}`) - continue - } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - persistedStore!.markSshRemotePtyLease(targetId, relayPtyId, 'terminated') - reportStoppedPtyToRuntime(appPtyId) - if (result.status === 'fulfilled') { - options.onStopped?.(appPtyId) - } - outcome = { ...outcome, terminated: outcome.terminated + 1 } - } - if (shutdownFailures.length > 0) { - // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. - throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) - } - // Disposal marks every remaining lease terminated; an unverifiable PTY keeps its lease detached, - // since only the leases this run proved terminated were marked so above. - await teardownSshTargetTransport(targetId, (session) => - outcome.unverifiable > 0 ? session.detachAndPersist() : session.disposeAndPersist() - ) - }) - return outcome -} - -/** An unanswered listing adds nothing here; the move's census after the stop still asks the host. */ -async function listRelayPtyIdsToStop( - targetId: string, - provider: ReturnType -): Promise { - if (!provider) { - return [] - } - const [current, previous] = await Promise.all([ - provider.listProcesses().then( - (rows) => rows.map((row) => row.id), - () => [] - ), - listPreviousRelayPtyIds(targetId).catch(() => null) - ]) - return [...current, ...(previous ?? [])] -} - -/** Marks exactly this shell, from just before its shutdown, so earlier exits close normally. */ -async function shutdownAs( - options: SshTerminateSessionsOptions, - appPtyId: string, - shutdown: () => Promise -): Promise { - const settle = options.intentionalStop - ? currentRuntime?.intentionalPtyStops.mark( - appPtyId, - options.intentionalStop, - ptyIncarnationById.get(appPtyId) ?? null - ) - : undefined - let stopped = false - try { - await shutdown() - stopped = true - } finally { - settle?.(stopped) - } -} - -/** - * Why: a relay that hangs up after its last shell stops never sends that exit, which left - * `terminal list` showing the stopped shell as connected. The relay accepting the kill is not an - * observed exit, so this is the stop sentinel, and a real exit already reported is kept. - */ -function reportStoppedPtyToRuntime(appPtyId: string): void { - if (currentRuntime?.getPtyLivenessVerdict(appPtyId)?.status === 'exited') { - return - } - currentRuntime?.onPtyExit(appPtyId, UNVERIFIED_PROCESS_EXIT_CODE) -} diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 8b07ca103b28..cb89f3f656dc 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,33 +21,34 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { RelayVersionMismatchError } from '../ssh/ssh-relay-version-mismatch-error' -import type { SshConnectionState, SshConnectionStatus, SshTarget } from '../../shared/ssh-types' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' import { createSshIpcHarness } from './ssh-ipc-test-harness' -import { decideHostServer } from './ssh-host-server-connect' -import { beginSshHostCensus } from '../ssh/ssh-connection-attribution' - -const { - mockSshStore, - mockConnectionManager, - mockDeployAndLaunchRelay, - mockMux, - mockRegisterSshGitProvider, - mockPortForwardManager -} = mocks +import { + decideHostServer, + publishManagedServerConnect, + publishUnservedHostServer +} from './ssh-host-server-connect' +import { beginSshHostCensus, recordSshConnectionOpened } from '../ssh/ssh-connection-attribution' +import type { SshConnection } from '../ssh/ssh-connection' + +const { mockSshStore, mockConnectionManager } = mocks + +const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' +} + +function asTransport(value: { id: string }): SshConnection { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attribution keys on identity only. + return value as unknown as SshConnection +} describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { - relayBuildId, - handlers, - mockWindow, - relayReconnectDelaysMs, - relayLostStabilizedMs, - getLatestRelayDisposeCallback, - useSlowRelayLaunchOnce - } = harness + const { handlers, mockWindow } = harness beforeEach(harness.reset) @@ -61,339 +60,50 @@ describe('SSH IPC handlers', () => { ) }) - it('ssh:connect calls connection manager', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } + it('publishes a managed connect without opening a relay', async () => { mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) - }) - it('registers the provider before broadcasting connected authority', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toMatchObject({ + status: 'connected' }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const connectedIndex = mockWindow.webContents.send.mock.calls.findIndex( - ([channel, payload]) => - channel === 'ssh:state-changed' && - (payload as { state: SshConnectionState }).state.status === 'connected' + expect(publishManagedServerConnect).toHaveBeenCalledWith( + 'ssh-1', + 'env-test', + undefined, + undefined ) - expect(connectedIndex).toBeGreaterThanOrEqual(0) - expect(mockRegisterSshGitProvider.mock.invocationCallOrder[0]).toBeLessThan( - mockWindow.webContents.send.mock.invocationCallOrder[connectedIndex] - ) - expect(mockWindow.webContents.send.mock.calls[connectedIndex]?.[1]).toEqual({ - targetId: 'ssh-1', - state: expect.objectContaining({ - targetId: 'ssh-1', - status: 'connected', - providerEpoch: expect.any(String), - connectionGeneration: 1 - }) - }) - }) - - it('ssh:connect exposes the detected remote platform in public state', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Windows Server', - host: 'windows.example.com', - port: 22, - username: 'deploy' - } - const hostPlatform = { - relayPlatform: 'win32-x64', - os: 'win32', - arch: 'x64', - pathFlavor: 'windows', - commandDialect: 'powershell', - pathSeparator: '\\', - pathDelimiter: ';' - } - mockDeployAndLaunchRelay.mockResolvedValueOnce({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - serverBuildId: relayBuildId, - hostPlatform - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 1, - remotePlatform: 'win32' - }) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 1, - supportsFolderDownload: true, - remotePlatform: 'win32' - } - }) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() }) - it('surfaces relay channel loss while the SSH connection remains alive', async () => { - vi.useFakeTimers() - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' + it('fails a host the managed server does not serve, with no fallback', async () => { + const decision = { + route: 'relay' as const, + reason: 'orcad_unavailable' as const, + detail: 'unsupported_host' } - const conn = {} + const opened = { id: 'decision-transport' } mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as - | ((reason: 'shutdown' | 'connection_lost') => void) - | undefined - - onDispose?.('connection_lost') - - const reconnectingState = handlers.get('ssh:getState')!(null, { - targetId: 'ssh-1' - }) as SshConnectionState - expect(reconnectingState).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: reconnectingState - }) - - await vi.advanceTimersByTimeAsync(500) - - const connectedState = handlers.get('ssh:getState')!(null, { - targetId: 'ssh-1' - }) as SshConnectionState - expect(connectedState).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: reconnectingState.providerEpoch, - connectionGeneration: reconnectingState.connectionGeneration - }) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - ...connectedState, - supportsFolderDownload: true - } - }) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).toThrow( - 'SSH connection changed; refresh and try again' - ) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() - } finally { - vi.useRealTimers() - } - }) - - it('rejects a staged mutation after the underlying SSH transport reconnects', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const stagedGeneration = 1 - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'reconnecting', - error: null, - reconnectAttempt: 1 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel reconnecting...', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', stagedGeneration)).toThrow( - 'SSH connection changed; refresh and try again' - ) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() - }) - - // Why: reproduces the "Infinite reconnect bug" — when the raw SSH transport - // connects but relay deploy fails permanently (dev build missing the platform - // relay package), doConnect must not leak the transport's premature 'connected' - // to the renderer. The renderer treats 'connected' as "session fully up" and - // remounts SSH panes (-> window.api.ssh.connect); a premature 'connected' on - // every failing attempt drives an unbounded reconnect loop. - it('does not broadcast a premature connected when relay deploy fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - // Why: mirror the real SshConnection — connect() drives the raw transport to - // 'connected' via onStateChange BEFORE the relay session establishes. The await - // yields a microtask so this lands after connectTarget records connectInFlight, - // matching the real ssh2 'ready' event (which fires async, post connect() call). - mockConnectionManager.connect.mockImplementation(async () => { - await Promise.resolve() - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connecting', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - supportsFolderDownload: true - }) - return conn + vi.mocked(decideHostServer).mockImplementationOnce(async () => { + recordSshConnectionOpened(asTransport(opened)) + mockConnectionManager.getConnection.mockReturnValue(opened) + return decision }) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockDeployAndLaunchRelay - .mockReset() - .mockRejectedValue( - new Error( - 'Relay package for linux-x64 not found locally. ' + - 'This may be a packaging issue — try reinstalling Orca.' - ) - ) await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'not found locally' + 'This SSH host isn’t supported' ) - - // Main performs exactly one connect + one disconnect per IPC (no main-side loop). - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - - // The renderer must never see 'connected' for a connect whose relay never - // became ready — doConnect broadcasts the authoritative 'connected' only after - // establish() succeeds, which it does not here. - const connectedBroadcasts = mockWindow.webContents.send.mock.calls.filter( - ([channel, payload]) => - channel === 'ssh:state-changed' && - (payload as { state?: SshConnectionState }).state?.status === 'connected' - ) - expect(connectedBroadcasts).toEqual([]) + expect(publishUnservedHostServer).toHaveBeenCalledWith(target, decision) + expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() }) - it('holds a raw connected the server decision causes before any relay session exists', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } + it('holds a raw connected the server decision causes', async () => { const conn = {} mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) mockConnectionManager.getConnection.mockReturnValue(conn) let broadcastsDuringDecision: unknown[] = [] vi.mocked(decideHostServer).mockImplementationOnce(async () => { await Promise.resolve() - // The census dials the shared pool before doConnect has a session. const callbacks = mockConnectionManager.callbacksRef.current as { onStateChange: (targetId: string, state: SshConnectionState) => void } @@ -406,7 +116,7 @@ describe('SSH IPC handlers', () => { broadcastsDuringDecision = mockWindow.webContents.send.mock.calls .filter(([channel]) => channel === 'ssh:state-changed') .map(([, payload]) => payload) - return null + return { route: 'managed', environmentId: 'env-test' } }) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) @@ -416,46 +126,6 @@ describe('SSH IPC handlers', () => { expect(broadcastsDuringDecision.at(-1)).toMatchObject({ state: { status: 'connecting' } }) }) - it("never broadcasts 'connected' while a move's census runs with no relay session", async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - harness.mockStore.getSshRemotePtyLeases.mockReturnValue([]) - let broadcastsDuringCensus: unknown[] = [] - vi.mocked(decideHostServer).mockImplementationOnce(async () => { - await Promise.resolve() - // The move's census is the reconnect's decision, dialing the pool before any session. - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - broadcastsDuringCensus = mockWindow.webContents.send.mock.calls - .filter(([channel]) => channel === 'ssh:state-changed') - .map(([, payload]) => payload) - return null - }) - - await handlers.get('ssh:moveToManagedServer')!(null, { targetId: 'ssh-1' }) - expect(vi.mocked(decideHostServer)).toHaveBeenCalled() - expect(broadcastsDuringCensus.length).toBeGreaterThan(0) - expect(broadcastsDuringCensus).not.toContainEqual( - expect.objectContaining({ state: expect.objectContaining({ status: 'connected' }) }) - ) - }) - it("holds a census's raw 'connected' outside any connect", () => { const end = beginSshHostCensus('ssh-1') try { @@ -477,399 +147,4 @@ describe('SSH IPC handlers', () => { end() } }) - - // Why: guards the fix's scope. A relay version mismatch during a relay reconnect - // strands the session 'idle' in activeSessions (only doConnect deletes it). A later - // transport blip then delivers a raw 'connected' with NO connect in flight — the - // 'deploying-relay' hold must NOT fire there (it would wedge the UI on an eternal - // spinner with every reconnect/reset control disabled). The hold is gated to live - // connects via connectInFlight. - it('does not hold a stray connected as deploying-relay when no connect is in flight', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - // Establish a ready relay session, then lose the relay and fail the reconnect with - // a version mismatch so the session is left stranded 'idle' in activeSessions. - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockDeployAndLaunchRelay - .mockReset() - .mockRejectedValue(new RelayVersionMismatchError('2.0.0', '1.0.0')) - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - - // The terminal relay error is surfaced; the session is now stranded 'idle'. - expect( - (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).status - ).toBe('error') - - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - mockWindow.webContents.send.mockClear() - // A transport blip on the still-live SSH socket auto-recovers to 'connected' with - // no ssh:connect in flight (connectInFlight is empty). - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'reconnecting', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - // The stray 'connected' is forwarded as-is — never wedged at 'deploying-relay'. - const stateChanges = mockWindow.webContents.send.mock.calls.filter( - ([channel]) => channel === 'ssh:state-changed' - ) - const lastStateChange = stateChanges.at(-1) - expect(lastStateChange).toBeDefined() - expect((lastStateChange![1] as { state: SshConnectionState }).state.status).toBe('connected') - const heldAsDeploying = stateChanges.some( - ([, payload]) => - (payload as { state?: SshConnectionState }).state?.status === 'deploying-relay' - ) - expect(heldAsDeploying).toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('rebuilds instead of reusing a ready session while relay loss is pending', async () => { - vi.useFakeTimers() - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as - | ((reason: 'shutdown' | 'connection_lost') => void) - | undefined - - onDispose?.('connection_lost') - - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - - mockDeployAndLaunchRelay.mockClear() - mockPortForwardManager.removeAllForwards.mockClear() - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 3 - }) - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockDeployAndLaunchRelay).toHaveBeenCalled() - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 3 - }) - } finally { - vi.useRealTimers() - } - }) - - it('keeps counting slow unstable relay reconnects until manual reconnect is required', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - for (const [index, delayMs] of relayReconnectDelaysMs.entries()) { - useSlowRelayLaunchOnce(relayLostStabilizedMs + 1) - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(delayMs + relayLostStabilizedMs + 1) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: index + 2 - }) - } - - getLatestRelayDisposeCallback()('connection_lost') - - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'error', - error: 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: relayReconnectDelaysMs.length + 2 - }) - } finally { - vi.useRealTimers() - } - }) - - describe('relay loss while the SSH transport is down', () => { - const relayLostTarget: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const transportState = (status: SshConnectionStatus): SshConnectionState => ({ - targetId: 'ssh-1', - status, - error: null, - reconnectAttempt: 0 - }) - const setTransportStatus = (status: SshConnectionStatus): void => { - mockConnectionManager.getState.mockReturnValue(transportState(status)) - } - const maxRelayDelayMs = relayReconnectDelaysMs.at(-1)! - const connectWithLiveTransport = async (): Promise => { - mockSshStore.getTarget.mockReturnValue(relayLostTarget) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getConnection.mockReturnValue({}) - setTransportStatus('connected') - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockDeployAndLaunchRelay.mockClear() - } - - it('does not consume attempts or publish the manual-reconnect banner', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - setTransportStatus('reconnecting') - getLatestRelayDisposeCallback()('connection_lost') - - // Well past the whole 6-step ladder: a redeploy cannot ride a dead transport, so nothing is spent. - const fullLadderMs = relayReconnectDelaysMs.reduce((sum, delay) => sum + delay, 0) - await vi.advanceTimersByTimeAsync(fullLadderMs + relayLostStabilizedMs) - - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - - setTransportStatus('connected') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - expect( - (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).status - ).toBe('connected') - } finally { - vi.useRealTimers() - } - }) - - it('stops retrying once the transport reaches a terminal state', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - setTransportStatus('reconnecting') - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs) - - setTransportStatus('reconnection-failed') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs) - - // The wait loop is gone: only onStateChange's redeploy may revive the relay after this. - setTransportStatus('connected') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs * 4) - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('resets the relay budget when the connection disappears before retry', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - getLatestRelayDisposeCallback()('connection_lost') - - mockConnectionManager.getConnection.mockReturnValue(undefined) - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - - mockConnectionManager.getConnection.mockReturnValue({}) - getLatestRelayDisposeCallback()('connection_lost') - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual( - expect.objectContaining({ reconnectAttempt: 1 }) - ) - } finally { - vi.useRealTimers() - } - }) - - it('still reaches the manual-reconnect banner when the transport is healthy', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) - getLatestRelayDisposeCallback()('connection_lost') - for (const delayMs of relayReconnectDelaysMs) { - await vi.advanceTimersByTimeAsync(delayMs) - } - - expect( - (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).error - ).toBe('Relay channel kept dropping. Click Reconnect on the SSH target before retrying.') - } finally { - vi.useRealTimers() - } - }) - - it('restores the full relay budget once the transport reconnects', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - callbacks.onStateChange('ssh-1', transportState('reconnecting')) - callbacks.onStateChange('ssh-1', transportState('connected')) - await vi.advanceTimersByTimeAsync(0) - - // Budget reset: the next loss waits the base delay again instead of the third ladder step. - mockDeployAndLaunchRelay.mockClear() - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - }) - - it('reuses a fast relay reconnect after the post-ready stabilization window', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - - await vi.advanceTimersByTimeAsync(relayLostStabilizedMs + 1) - mockDeployAndLaunchRelay.mockClear() - mockPortForwardManager.removeAllForwards.mockClear() - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index cb993f03e2ad..a3fa33d6f1b9 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1,22 +1,13 @@ -import { - AiVaultSearchRequestSchema, - AiVaultSearchStatusRequestSchema -} from '../../shared/ai-vault-search-contract' import { ipcMain, type BrowserWindow } from 'electron' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' import { SshConnectionManager } from '../ssh/ssh-connection-manager' -import type { SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' -import type { - SshAiVaultRelayListParams, - SshAiVaultRelayTitleParams -} from '../../shared/ssh-ai-vault-relay' import { SshPortForwardManager } from '../ssh/ssh-port-forward' -import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { getSshTargetRegistryStore, setSshConnectionManagerResolver, + setSshTargetMutations, setSshTargetRegistryHandlers, setSshTargetRegistryStore } from '../ssh/ssh-target-registry' @@ -40,17 +31,10 @@ import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' -import { activeSessions } from './ssh-active-relay-sessions' import { installManagedOrcadStartStatus } from './runtime-environment-managed-tunnel' -import { - registerAdvertisedUrlRefresh, - unregisterAdvertisedUrlRefresh -} from './ssh-advertised-url-refresh' import { connectInFlight, credentialRequestedForTarget, - pendingTransportReconnects, - resetRelayInFlight, testConnectionProbes, testingTargets } from './ssh-connect-attempt-registry' @@ -72,9 +56,7 @@ import { } from './ssh-ipc-context' import { registerSshPortForwardHandlers } from './ssh-port-forward-handlers' import { persistPortForwardsWithUnrestored } from './ssh-port-forward-persistence' -import { clearRelayLostBackoff, relayLostBackoff } from './ssh-relay-lost-backoff' -import { refreshActiveRelaySessions } from './ssh-relay-session-callbacks' -import { broadcastPortForwards, relayStateOverrides } from './ssh-renderer-broadcast' +import { broadcastPortForwards } from './ssh-renderer-broadcast' import { resetSshShutdownDrain } from './ssh-shutdown-drain' import { registerSshTargetCrudHandlers } from './ssh-target-crud-handlers' import { targetLifecycleInFlight } from './ssh-target-lifecycle-queue' @@ -94,9 +76,6 @@ const SSH_IPC_CHANNELS = [ 'ssh:resolveConfigHost', 'ssh:connect', 'ssh:disconnect', - 'ssh:terminateSessions', - 'ssh:moveToManagedServer', - 'ssh:resetRelay', 'ssh:getState', 'ssh:needsPassphrasePrompt', 'ssh:testConnection', @@ -107,74 +86,6 @@ const SSH_IPC_CHANNELS = [ 'ssh:listDetectedPorts' ] as const -export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { - if (isRuntimeOwnedSshTargetId(targetId)) { - return null - } - return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null -} - -export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { - return [...activeSessions.values()].flatMap((session) => { - if (isRuntimeOwnedSshTargetId(session.targetId)) { - return [] - } - const info = session.getAiVaultHostInfo() - return info ? [info] : [] - }) -} - -export async function requestActiveSshSessionSearch( - targetId: string, - method: string, - params: unknown -): Promise { - if (isRuntimeOwnedSshTargetId(targetId)) { - throw new Error('SSH target belongs to another runtime') - } - const session = activeSessions.get(targetId) - if (!session) { - throw new Error('SSH relay is not ready') - } - if (method === 'aiVault.searchSessions') { - return session.requestSessionSearch(method, AiVaultSearchRequestSchema.parse(params)) - } - if (method === 'aiVault.searchStatus') { - return session.requestSessionSearch(method, AiVaultSearchStatusRequestSchema.parse(params)) - } - throw new Error('Unknown session search method') -} - -export async function requestActiveSshAiVaultSessionList( - targetId: string, - params: SshAiVaultRelayListParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { - if (isRuntimeOwnedSshTargetId(targetId)) { - return null - } - const session = activeSessions.get(targetId) - if (!session) { - throw new Error('SSH relay is not ready') - } - return session.requestAiVaultSessionList(params, options) -} - -export async function requestActiveSshAiVaultSessionTitles( - targetId: string, - params: SshAiVaultRelayTitleParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { - if (isRuntimeOwnedSshTargetId(targetId)) { - return null - } - const session = activeSessions.get(targetId) - if (!session) { - throw new Error('SSH relay is not ready') - } - return session.requestAiVaultSessionTitles(params, options) -} - export function registerSshHandlers( store: Store, getMainWindow: () => BrowserWindow | null, @@ -192,7 +103,6 @@ export function registerSshHandlers( setPersistedStore(store) reconcileManagedOrcadSshTargets(getAppEnvironment().getPath('userData'), store) installOrcadMigrationScrollbackRetention(getAppEnvironment().getPath('userData'), store) - registerAdvertisedUrlRefresh(getCurrentMainWindow) installManagedOrcadStartStatus() registerCredentialHandler() @@ -217,7 +127,6 @@ export function registerSshHandlers( broadcastPortForwards(getCurrentMainWindow, entry.connectionId) } }) - refreshActiveRelaySessions() registerPowerMonitorReconnect(() => getAppEnvironment().getPath('userData')) registerSshBrowseHandler(() => connectionManager) setSshConnectionManagerResolver(() => connectionManager) @@ -233,28 +142,16 @@ export function registerSshHandlers( } export async function resetSshHandlerStateForTests(): Promise { - unregisterAdvertisedUrlRefresh() unregisterPowerMonitorReconnect() for (const ch of SSH_IPC_CHANNELS) { ipcMain.removeHandler(ch) } ipcMain.removeHandler('ssh:submitCredential') - // Why: allSettled — a rejected disposal write must not abort the rest of the reset and leak state into the next test. - await Promise.allSettled( - [...activeSessions.values()].map((session) => session.disposeAndPersist()) - ) - activeSessions.clear() - for (const targetId of relayLostBackoff.keys()) { - clearRelayLostBackoff(targetId) - } - relayStateOverrides.clear() connectInFlight.clear() targetLifecycleInFlight.clear() - pendingTransportReconnects.clear() resetSshConnectionGenerations() resetSshProviderAuthorities() - resetRelayInFlight.clear() testingTargets.clear() testConnectionProbes.clear() credentialRequestedForTarget.clear() @@ -270,6 +167,7 @@ export async function resetSshHandlerStateForTests(): Promise { setSshTargetRegistryStore(null) setPersistedStore(null) setSshTargetRegistryHandlers({ connect: null, getState: null }) + setSshTargetMutations(null) setCurrentGetMainWindow(() => null) setCurrentRuntime(undefined) } diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index eacd2cd133f6..84071ac3c2c7 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi, type Mock } from 'vitest' -import { validateGitExecArgs } from '../../relay/git-exec-validator' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { @@ -84,20 +83,6 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) }) - it('sends only argv the relay accepts, so SSH cleanup is not silently skipped', async () => { - const exec = makeExec() - await cleanupUnusedWorktreePushTargetRemoteWithExec( - REPO_PATH, - 'repo-1::/wt/a', - forkTarget(), - storeOf({ 'repo-1::/wt/a': forkTarget() }), - exec - ) - for (const [args] of exec.mock.calls) { - expect(() => validateGitExecArgs(args)).not.toThrow() - } - }) - it('keeps a remote Orca did not create (remoteCreated falsy)', async () => { const exec = makeExec() await cleanupUnusedWorktreePushTargetRemoteWithExec( diff --git a/src/main/ipc/worktree-push-target-reconciliation.test.ts b/src/main/ipc/worktree-push-target-reconciliation.test.ts index ed29809a151f..d1f37561c15f 100644 --- a/src/main/ipc/worktree-push-target-reconciliation.test.ts +++ b/src/main/ipc/worktree-push-target-reconciliation.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi, type Mock } from 'vitest' -import { validateGitExecArgs } from '../../relay/git-exec-validator' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' @@ -234,25 +233,6 @@ describe('reconcileOrphanedPrRemotesWithExec', () => { expect(reclaimed).toEqual([]) }) - it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => { - // Exercise every branch (config probe, for-each-ref probe, and the reclaim itself). - const exec = makeExec({ - remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), - branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, - localBranches: 'main' - }) - await reconcileOrphanedPrRemotesWithExec( - REPO_PATH, - REPO_ID, - storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), - exec, - [] - ) - expect(exec.mock.calls.length).toBeGreaterThan(0) - for (const [args] of exec.mock.calls) { - expect(() => validateGitExecArgs(args)).not.toThrow() - } - }) }) describe('reconcileOrphanedPrRemotes rate limiting', () => { diff --git a/src/main/ipc/worktrees-create-execution-host-routing.test.ts b/src/main/ipc/worktrees-create-execution-host-routing.test.ts deleted file mode 100644 index 36f1e816ea65..000000000000 --- a/src/main/ipc/worktrees-create-execution-host-routing.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - addWorktreeMock, - getActiveMultiplexerMock, - getSshGitProviderMock, - listWorktreesMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -const REMOTE_REPO_PATH = '/remote/repo' - -function makeRepo(fields: Record) { - return { - id: 'repo-1', - path: REMOTE_REPO_PATH, - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: 'origin/main', - ...fields - } -} - -function makeProvider(worktreePath: string) { - return { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - // A hit here reads as "branch already exists"; the create loop would then rename. - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: worktreePath, - head: 'abc123', - branch: 'refs/heads/wt', - isBare: false, - isMainWorktree: false - } - ]) - } -} - -function useRepo(repo: ReturnType): void { - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) -} - -describe('worktrees:create execution host routing', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('creates on the SSH host for a row that names it only as executionHostId', async () => { - // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client - // against `/remote/repo`. The runtime sibling already resolved this row remotely. - useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(provider.addWorktree).toHaveBeenCalledTimes(1) - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('keeps two simultaneously registered SSH hosts apart', async () => { - useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) - const providerA = makeProvider('/remote/repo-wt-a') - const providerB = makeProvider('/remote/repo-wt-b') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(providerB.addWorktree).toHaveBeenCalledTimes(1) - expect(providerA.addWorktree).not.toHaveBeenCalled() - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { - useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) - - await expect( - handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') - - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { - // `target-a` names a target inside env-1. A same-named one registered here is another machine, - // so creating through it lands the checkout on the wrong host. - useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await expect( - handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') - - expect(provider.addWorktree).not.toHaveBeenCalled() - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('answers local for a row that declares itself local while carrying a connection', async () => { - // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has - // always read it that way. The raw field sent it remote, so the two entry points disagreed. - useRepo( - makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) - ) - listWorktreesMock.mockResolvedValue([ - { - path: '/workspace/wt', - head: 'abc123', - branch: 'wt', - isBare: false, - isMainWorktree: false - } - ]) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(addWorktreeMock).toHaveBeenCalledTimes(1) - expect(provider.addWorktree).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index 95fdcc27b308..77e2557f0c6f 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -14,7 +14,9 @@ import { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, - getSshPtyProviderMock + getSshPtyProviderMock, + parseOrcaYamlMock, + hasHooksFileMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' @@ -355,3 +357,423 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree execution-host disambiguation', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + } + const sshRepo = { + ...localRepo, + path: '/remote/repo', + displayName: 'ssh', + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(localRepo) + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt', + hostId: 'ssh:conn-1' + }) + + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a row whose execution host and connection id name different machines', async () => { + // #11163: everything below the handler picks the filesystem from `repo.connectionId` while the + // prune, the archive-hook route and the home guard come from the resolved execution host. This + // row would have listed a remote checkout on this client and deleted a same-named local path. + const brokenRepo = { + id: 'repo-host-only', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: null, + executionHostId: 'ssh:conn-1' as const + } + const provider = { + listWorktrees: vi.fn(), + removeWorktree: vi.fn(), + worktreeIsClean: vi.fn() + } + store.getRepo.mockReturnValue(brokenRepo) + store.getRepos.mockReturnValue([brokenRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-host-only::/remote/feature-wt', + force: true + }) + ).rejects.toThrow( + 'Refusing to delete worktree: repo repo-host-only names execution host ssh:conn-1, but its checkout is only reachable as local.' + ) + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(provider.listWorktrees).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses the mirror row that names local while carrying a connection id', async () => { + const brokenRepo = { + id: 'repo-local-spelled', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + executionHostId: 'local' as const + } + store.getRepo.mockReturnValue(brokenRepo) + store.getRepos.mockReturnValue([brokenRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-local-spelled::/remote/feature-wt', + force: true + }) + ).rejects.toThrow( + 'Refusing to delete worktree: repo repo-local-spelled names execution host local, but its checkout is only reachable as ssh:conn-1.' + ) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a row owned by a runtime environment this process does not execute', async () => { + // `runtime:` deletes on that environment's own server. Its repo row looks local here — + // `connectionId` is null — so a host id that fell back to "local" would delete a same-named + // path on this client. + const runtimeRepo = { + id: 'repo-runtime-owned', + path: '/env/repo', + displayName: 'env', + badgeColor: '#000', + addedAt: 0, + connectionId: null, + executionHostId: 'runtime:env-1' as const + } + store.getRepo.mockReturnValue(runtimeRepo) + store.getRepos.mockReturnValue([runtimeRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-runtime-owned::/env/feature-wt', + force: true + }) + ).rejects.toThrow('Refusing to delete worktree: repo repo-runtime-owned names execution host') + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('accepts a row whose two spellings differ only by percent-escaping', async () => { + // `getRepoExecutionHostId` returns the stored text while the connection id is re-encoded, so a + // byte compare would refuse this consistent row over a `%20`. + const repo = { + id: 'repo-escaped', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn one', + executionHostId: 'ssh:conn one' as const + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: repo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([repo]) + getSshGitProviderMock.mockReturnValue(provider) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-escaped::/remote/feature-wt' + }) + + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + }) + + it('tears down the remote session when an ownerless remote worktree is deleted', async () => { + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(sshRepo) + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + // Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source. + store.getWorktreeMeta.mockReturnValue(undefined) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/remote/feature-wt' + }) + + // The whole point: without the repo's host the ownerless row would clear the local session instead. + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/remote/feature-wt', + 'ssh:conn-1' + ) + }) + + it('fails closed when duplicate repo ids are deleted without a host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt' + }) + ).rejects.toThrow('Repo not found: repo-shared') + + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('inspects hooks on the requested host when repo ids collide', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n archive: remote-cleanup', + isBinary: false + }) + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) + + await expect( + handlers['hooks:check'](null, { + repoId: 'repo-shared', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({ + status: 'ok', + hasHooks: true, + hooks: { scripts: { archive: 'remote-cleanup' } }, + mayNeedUpdate: false + }) + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('fails hook inspection closed when duplicate repo ids omit the host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({ + status: 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + }) + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('inspects setup-script imports on the requested host when repo ids collide', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + const fsProvider = { + readFile: vi.fn(async (filePath: string) => { + if (filePath === '/remote/repo/.superset/config.json') { + return { content: '{"setup":"remote setup"}', isBinary: false } + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }), + stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + store.getRepo.mockReturnValue(localRepo) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await expect( + handlers['hooks:inspectSetupScriptImports'](null, { + repoId: 'repo-shared', + hostId: 'ssh:conn-1' + }) + ).resolves.toContainEqual( + expect.objectContaining({ + provider: 'superset', + setup: 'remote setup' + }) + ) + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') + }) + + it('does not coalesce forget requests for the same id on different hosts', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + }) + + // The repos are gone (host removed), so only the single global meta key is left to + // key on — and it names one host. It must not merge two hosts' forgets into one. + it('does not let stale metadata coalesce an explicit host forget onto another host', async () => { + const worktreeId = 'repo-gone::/same/path' + store.getRepos.mockReturnValue([]) + store.getRepo.mockReturnValue(undefined) + store.getWorktreeMeta.mockReturnValue({ hostId: 'local' }) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') + }) +}) diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 05287f3d713c..07818ed7d308 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -7,7 +7,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createIssueCommandRunnerScriptMock, getSshFilesystemProviderMock, - getSshGitProviderMock + getSshGitProviderMock, + addWorktreeMock, + getActiveMultiplexerMock, + listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -93,6 +96,56 @@ vi.mock('../runtime/worktree-teardown', async () => ) vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) +const REMOTE_REPO_PATH = '/remote/repo' + +function makeRepo(fields: Record) { + return { + id: 'repo-1', + path: REMOTE_REPO_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main', + ...fields + } +} + +function makeProvider(worktreePath: string) { + return { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + // A hit here reads as "branch already exists"; the create loop would then rename. + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: worktreePath, + head: 'abc123', + branch: 'refs/heads/wt', + isBare: false, + isMainWorktree: false + } + ]) + } +} + +function useRepo(repo: ReturnType): void { + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) +} + describe('registerWorktreeHandlers', () => { beforeEach(() => { setupWorktreeHandlers() @@ -369,3 +422,92 @@ describe('registerWorktreeHandlers', () => { ) }) }) + +describe('worktrees:create execution host routing', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('creates on the SSH host for a row that names it only as executionHostId', async () => { + // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client + // against `/remote/repo`. The runtime sibling already resolved this row remotely. + useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(provider.addWorktree).toHaveBeenCalledTimes(1) + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('keeps two simultaneously registered SSH hosts apart', async () => { + useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) + const providerA = makeProvider('/remote/repo-wt-a') + const providerB = makeProvider('/remote/repo-wt-b') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(providerB.addWorktree).toHaveBeenCalledTimes(1) + expect(providerA.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { + useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { + // `target-a` names a target inside env-1. A same-named one registered here is another machine, + // so creating through it lands the checkout on the wrong host. + useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(provider.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('answers local for a row that declares itself local while carrying a connection', async () => { + // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has + // always read it that way. The raw field sent it remote, so the two entry points disagreed. + useRepo( + makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) + ) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/wt', + head: 'abc123', + branch: 'wt', + isBare: false, + isMainWorktree: false + } + ]) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(addWorktreeMock).toHaveBeenCalledTimes(1) + expect(provider.addWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts deleted file mode 100644 index bd3501abbe7f..000000000000 --- a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - removeWorktreeMock, - forceDeleteLocalBranchMock, - gitExecFileAsyncMock, - getSshGitProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('preserves the branch on remove for worktrees created from an existing local branch', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true })) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(removeWorktreeMock).toHaveBeenCalledWith( - '/workspace/repo', - '/workspace/feature-wt', - false, - expect.objectContaining({ - deleteBranch: false, - knownRemovedWorktree: expect.objectContaining({ - branch: 'feature', - head: 'feature', - path: '/workspace/feature-wt' - }) - }) - ) - }) - - it('force-deletes a branch that was preserved by safe worktree removal', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'def456' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( - '/workspace/repo', - 'feature/test', - 'def456' - ) - }) - - it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const worktreeId = 'repo-ssh::/remote/feature-wt' - const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined } - const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'def456', - branch: 'feature/test', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepos.mockReturnValue([localRepo, repo]) - store.getRepo.mockReturnValue(localRepo) - store.getWorktreeMeta.mockReturnValue( - makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true }) - ) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() }) - - await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' }) - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId, - branchName: 'feature/test', - expectedHead: 'def456', - hostId: 'ssh:conn-1' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( - '/remote/repo', - 'feature/test', - 'def456' - ) - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('rejects stale preserved-branch cleanup actions with an old head', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'new456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - await expect( - handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'old123' - }) - ).rejects.toThrow('No preserved branch cleanup is pending') - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('removes an unused Orca-created fork remote after deleting its worktree', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) - - it('keeps an Orca-created fork remote while another worktree still uses it', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-1::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( - ['remote', 'remove', 'pr-contributor-orca'], - expect.any(Object) - ) - }) - - it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-2::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) -}) diff --git a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts deleted file mode 100644 index ed10ef8f6ade..000000000000 --- a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - removeWorktreeMock, - listWorktreesMock, - parseOrcaYamlMock, - hasHooksFileMock, - getSshGitProviderMock, - getSshFilesystemProviderMock, - killAllProcessesForWorktreeMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: null - } - const sshRepo = { - ...localRepo, - path: '/remote/repo', - displayName: 'ssh', - connectionId: 'conn-1' - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: sshRepo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(localRepo) - store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshGitProviderMock.mockReturnValue(provider) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-shared::/remote/feature-wt', - hostId: 'ssh:conn-1' - }) - - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a row whose execution host and connection id name different machines', async () => { - // #11163: everything below the handler picks the filesystem from `repo.connectionId` while the - // prune, the archive-hook route and the home guard come from the resolved execution host. This - // row would have listed a remote checkout on this client and deleted a same-named local path. - const brokenRepo = { - id: 'repo-host-only', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: null, - executionHostId: 'ssh:conn-1' as const - } - const provider = { - listWorktrees: vi.fn(), - removeWorktree: vi.fn(), - worktreeIsClean: vi.fn() - } - store.getRepo.mockReturnValue(brokenRepo) - store.getRepos.mockReturnValue([brokenRepo]) - getSshGitProviderMock.mockReturnValue(provider) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-host-only::/remote/feature-wt', - force: true - }) - ).rejects.toThrow( - 'Refusing to delete worktree: repo repo-host-only names execution host ssh:conn-1, but its checkout is only reachable as local.' - ) - - expect(listWorktreesMock).not.toHaveBeenCalled() - expect(provider.listWorktrees).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses the mirror row that names local while carrying a connection id', async () => { - const brokenRepo = { - id: 'repo-local-spelled', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - executionHostId: 'local' as const - } - store.getRepo.mockReturnValue(brokenRepo) - store.getRepos.mockReturnValue([brokenRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-local-spelled::/remote/feature-wt', - force: true - }) - ).rejects.toThrow( - 'Refusing to delete worktree: repo repo-local-spelled names execution host local, but its checkout is only reachable as ssh:conn-1.' - ) - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a row owned by a runtime environment this process does not execute', async () => { - // `runtime:` deletes on that environment's own server. Its repo row looks local here — - // `connectionId` is null — so a host id that fell back to "local" would delete a same-named - // path on this client. - const runtimeRepo = { - id: 'repo-runtime-owned', - path: '/env/repo', - displayName: 'env', - badgeColor: '#000', - addedAt: 0, - connectionId: null, - executionHostId: 'runtime:env-1' as const - } - store.getRepo.mockReturnValue(runtimeRepo) - store.getRepos.mockReturnValue([runtimeRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-runtime-owned::/env/feature-wt', - force: true - }) - ).rejects.toThrow('Refusing to delete worktree: repo repo-runtime-owned names execution host') - - expect(listWorktreesMock).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('accepts a row whose two spellings differ only by percent-escaping', async () => { - // `getRepoExecutionHostId` returns the stored text while the connection id is re-encoded, so a - // byte compare would refuse this consistent row over a `%20`. - const repo = { - id: 'repo-escaped', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn one', - executionHostId: 'ssh:conn one' as const - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(repo) - store.getRepos.mockReturnValue([repo]) - getSshGitProviderMock.mockReturnValue(provider) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-escaped::/remote/feature-wt' - }) - - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - }) - - it('tears down the remote session when an ownerless remote worktree is deleted', async () => { - const sshRepo = { - id: 'repo-1', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: sshRepo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(sshRepo) - store.getRepos.mockReturnValue([sshRepo]) - getSshGitProviderMock.mockReturnValue(provider) - // Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source. - store.getWorktreeMeta.mockReturnValue(undefined) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/remote/feature-wt' - }) - - // The whole point: without the repo's host the ownerless row would clear the local session instead. - expect(store.removeWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/remote/feature-wt', - 'ssh:conn-1' - ) - }) - - it('fails closed when duplicate repo ids are deleted without a host', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-shared::/remote/feature-wt' - }) - ).rejects.toThrow('Repo not found: repo-shared') - - expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() - }) - - it('inspects hooks on the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n archive: remote-cleanup', - isBinary: false - }) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) - - await expect( - handlers['hooks:check'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({ - status: 'ok', - hasHooks: true, - hooks: { scripts: { archive: 'remote-cleanup' } }, - mayNeedUpdate: false - }) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') - expect(hasHooksFileMock).not.toHaveBeenCalled() - }) - - it('fails hook inspection closed when duplicate repo ids omit the host', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - - await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({ - status: 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - }) - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() - expect(hasHooksFileMock).not.toHaveBeenCalled() - }) - - it('inspects setup-script imports on the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath === '/remote/repo/.superset/config.json') { - return { content: '{"setup":"remote setup"}', isBinary: false } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - store.getRepo.mockReturnValue(localRepo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:inspectSetupScriptImports'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toContainEqual( - expect.objectContaining({ - provider: 'superset', - setup: 'remote setup' - }) - ) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') - }) - - it('does not coalesce forget requests for the same id on different hosts', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - let finishFirst!: () => void - killAllProcessesForWorktreeMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => - resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - }) - ) - .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - - const first = handlers['worktrees:forgetLocal'](null, { - worktreeId: 'repo-shared::/same/path', - hostId: 'local' - }) as Promise - await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) - - await expect( - handlers['worktrees:forgetLocal'](null, { - worktreeId: 'repo-shared::/same/path', - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({}) - expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) - - finishFirst() - await expect(first).resolves.toEqual({}) - }) - - // The repos are gone (host removed), so only the single global meta key is left to - // key on — and it names one host. It must not merge two hosts' forgets into one. - it('does not let stale metadata coalesce an explicit host forget onto another host', async () => { - const worktreeId = 'repo-gone::/same/path' - store.getRepos.mockReturnValue([]) - store.getRepo.mockReturnValue(undefined) - store.getWorktreeMeta.mockReturnValue({ hostId: 'local' }) - let finishFirst!: () => void - killAllProcessesForWorktreeMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => - resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - }) - ) - .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - - const first = handlers['worktrees:forgetLocal'](null, { - worktreeId, - hostId: 'local' - }) as Promise - await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) - - await expect( - handlers['worktrees:forgetLocal'](null, { - worktreeId, - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({}) - expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) - - finishFirst() - await expect(first).resolves.toEqual({}) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') - }) -}) diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index cbf4c904096a..bfb857da5f4c 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -6,7 +6,12 @@ import { getEffectiveHooksMock, createSetupRunnerScriptMock, getEffectiveHooksFromConfigMock, - shouldRunSetupForCreateMock + shouldRunSetupForCreateMock, + parseOrcaYamlMock, + resolveSetupRunnerShellMock, + getSshGitProviderMock, + getSshFilesystemProviderMock, + getActiveMultiplexerMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { createdWorktreeList } from './worktrees-test-fixtures' @@ -404,3 +409,316 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree SSH setup launch', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/old-improve-dashboard', + head: 'old123', + branch: 'refs/heads/archive/improve-dashboard', + isBare: false, + isMainWorktree: false + }, + { + path: '/remote/repo-improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ + scripts: { setup: 'pnpm install' }, + setupAgentStartupPolicy: 'wait-for-setup' + }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo-improve-dashboard/orca.yaml') + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + '/remote/repo-improve-dashboard' + ) + expect(fsProvider.createDir).toHaveBeenCalledWith( + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n' + ) + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/remote/repo', + ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard' + }), + waitForAgentStartup: true + } + }) + ) + }) + + it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { + const repo = { + id: 'repo-ssh', + path: 'C:\\remote\\repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: 'C:\\remote\\improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + terminalWindowsShell: 'git-bash', + workspaceDir: 'C:\\workspace' + }) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + 'C:\\remote\\improve-dashboard' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + 'pnpm install' + ) + expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\remote\\repo', + ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' + }) + } + }) + ) + }) + + it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-sparse-dashboard', + head: 'abc123', + branch: 'refs/heads/sparse-dashboard', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-ssh', + name: 'App', + directories: ['apps/mobile', 'packages/shared'], + createdAt: 1, + updatedAt: 1 + } + ]) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'sparse-dashboard', + sparseCheckout: { + directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'], + presetId: 'preset-1' + } + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'sparse-dashboard', + '/remote/repo-sparse-dashboard', + { base: 'origin/main', noCheckout: true } + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'init', '--cone'], + '/remote/repo-sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], + '/remote/repo-sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['checkout', 'sparse-dashboard'], + '/remote/repo-sparse-dashboard' + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-ssh::/remote/repo-sparse-dashboard', + expect.objectContaining({ + sparseDirectories: ['apps/mobile', 'packages/shared'], + baseRef: 'refs/remotes/origin/main', + sparseBaseRef: 'refs/remotes/origin/main', + sparsePresetId: 'preset-1' + }) + ) + expect(result).toMatchObject({ + worktree: expect.objectContaining({ + isSparse: true, + sparseDirectories: ['apps/mobile', 'packages/shared'], + sparseBaseRef: 'refs/remotes/origin/main', + sparsePresetId: 'preset-1' + }) + }) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts deleted file mode 100644 index 03e69583e99c..000000000000 --- a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getSshGitProviderMock, - getSshFilesystemProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'abc123' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { - return { stdout: 'abc123\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { - return { stdout: 'abc123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi - .fn() - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo-fix-title-2', - head: 'abc123', - branch: 'refs/heads/feature/fix', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - stat: vi.fn().mockImplementation(async (pathValue: string) => { - if (pathValue === '/remote/repo-fix-title') { - return { size: 0, type: 'directory', mtime: 0 } - } - const error = new Error('missing') as Error & { code: string } - error.code = 'ENOENT' - throw error - }), - readFile: vi.fn().mockRejectedValue(new Error('missing')) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'fix-title', - baseBranch: 'abc123', - branchNameOverride: 'feature/fix' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/fix', - '/remote/repo-fix-title-2', - { checkoutExistingBranch: true } - ) - expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { - rootPath: '/remote/repo-fix-title-2' - }) - }) - - it('suffixes SSH worktree creation when the requested branch already exists on a remote', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'branch' && args.includes('feature/something')) { - return { stdout: '', stderr: '' } - } - if (args[0] === 'show-ref') { - const ref = 'refs/remotes/origin/feature/something' - if (args.includes(ref)) { - return { stdout: `abc ${ref}\n`, stderr: '' } - } - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { - throw new Error('missing local branch') - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { - throw new Error('missing local branch') - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature-something-2', - head: 'abc123', - branch: 'refs/heads/feature/something-2', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'feature-something', - branchNameOverride: 'feature/something' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/something-2', - '/remote/repo-feature-something-2', - { base: 'origin/main' } - ) - }) - - it('suffixes SSH worktree creation when a slashed remote owns the requested branch', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\nfoo/bar\n', stderr: '' } - } - if (args[0] === 'show-ref') { - const ref = 'refs/remotes/foo/bar/feature/something' - if (args.includes(ref)) { - return { stdout: `abc ${ref}\n`, stderr: '' } - } - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { - throw new Error('missing local branch') - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { - throw new Error('missing local branch') - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature-something-2', - head: 'abc123', - branch: 'refs/heads/feature/something-2', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'feature-something', - branchNameOverride: 'feature/something' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/something-2', - '/remote/repo-feature-something-2', - { base: 'origin/main' } - ) - }) -}) diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts deleted file mode 100644 index fe80219fa8f9..000000000000 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { validateGitExecArgs } from '../../relay/git-exec-validator' -import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote' -import type { SshGitProvider } from '../providers/ssh-git-provider' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - // Was "adds the fork remote ... through git.exec": create used to mint the fork - // remote unconditionally. It now defers to first sync (#17828) -- split in two so - // each half stays true to a single claim: create stays a no-op, sync still mints. - it('defers minting the fork remote for an SSH fork-PR worktree until first sync', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - // Why: run every exec through the relay's own validator so a create shape the - // relay rejects fails here instead of on the user's SSH host. - const exec = vi.fn().mockImplementation(async (args: string[]) => { - validateGitExecArgs(args) - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }) - const provider = { - exec, - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-contributor-fix', - head: 'abc123', - branch: 'refs/heads/contributor/fix', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - - expect(exec).not.toHaveBeenCalledWith( - ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], - '/remote/repo' - ) - // fetchRemoteTrackingRef IS called once here, but for create's unrelated - // base-ref refresh (origin/main) -- not for the fork remote, which defers. - expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( - '/remote/repo', - 'pr-contributor-orca', - 'contributor/fix', - 'refs/remotes/pr-contributor-orca/contributor/fix' - ) - expect(store.setWorktreeMeta).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ) - }) - - // Companion to the deferral test above: `materializeWorktreePushTargetRemoteSsh` is - // exactly what `git:push`/`git:pull`'s SSH dispatch calls before syncing, so this is - // "first sync" without needing the sync IPC handlers registered in this harness. - it('mints the fork remote for an SSH fork-PR worktree on first sync', async () => { - const exec = vi.fn().mockImplementation(async (args: string[]) => { - validateGitExecArgs(args) - if (args[0] === 'remote' && args[1] === 'get-url') { - throw new Error('No such remote') - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - const fetchRemoteTrackingRef = vi.fn().mockResolvedValue(undefined) - const markRemoteOrcaCreated = vi.fn().mockResolvedValue(undefined) - const target = { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - - const result = await materializeWorktreePushTargetRemoteSsh( - { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, - '/remote/repo', - target - ) - - expect(result).toEqual({ ...target, remoteCreated: true }) - expect(exec).toHaveBeenCalledWith( - ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], - '/remote/repo' - ) - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/remote/repo', - 'pr-contributor-orca', - 'contributor/fix', - 'refs/remotes/pr-contributor-orca/contributor/fix' - ) - expect(markRemoteOrcaCreated).toHaveBeenCalledWith('/remote/repo', 'pr-contributor-orca') - }) - - // The relay-upgrade-messaging, fetch-failure rollback, and sibling-remote-preserved - // cases used to be exercised here because create minted the remote unconditionally. - // That code (prepareWorktreePushTargetSsh) is unchanged -- it just no longer runs at - // create time for a fork remote, only from materializeWorktreePushTargetRemoteSsh on - // first sync. Coverage for all three moved with it to - // worktree-remote-push-target-materialization.test.ts, which calls that function directly. -}) diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index 8d4966dd187e..034bd890bd18 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -4,7 +4,8 @@ import { getPullRequestPushTargetMock, gitExecFileAsyncMock, getSshGitProviderMock, - getActiveMultiplexerMock + getActiveMultiplexerMock, + getSshFilesystemProviderMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { ORIGIN_HEAD_COMPONENT, ORIGIN_REMOTE_URL } from './worktrees-test-fixtures' @@ -529,3 +530,248 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree SSH branch conflict suffixes', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'abc123' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi + .fn() + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/repo-fix-title-2', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + stat: vi.fn().mockImplementation(async (pathValue: string) => { + if (pathValue === '/remote/repo-fix-title') { + return { size: 0, type: 'directory', mtime: 0 } + } + const error = new Error('missing') as Error & { code: string } + error.code = 'ENOENT' + throw error + }), + readFile: vi.fn().mockRejectedValue(new Error('missing')) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/fix', + '/remote/repo-fix-title-2', + { checkoutExistingBranch: true } + ) + expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/remote/repo-fix-title-2' + }) + }) + + it('suffixes SSH worktree creation when the requested branch already exists on a remote', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'branch' && args.includes('feature/something')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'show-ref') { + const ref = 'refs/remotes/origin/feature/something' + if (args.includes(ref)) { + return { stdout: `abc ${ref}\n`, stderr: '' } + } + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature-something-2', + head: 'abc123', + branch: 'refs/heads/feature/something-2', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/something-2', + '/remote/repo-feature-something-2', + { base: 'origin/main' } + ) + }) + + it('suffixes SSH worktree creation when a slashed remote owns the requested branch', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\nfoo/bar\n', stderr: '' } + } + if (args[0] === 'show-ref') { + const ref = 'refs/remotes/foo/bar/feature/something' + if (args.includes(ref)) { + return { stdout: `abc ${ref}\n`, stderr: '' } + } + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature-something-2', + head: 'abc123', + branch: 'refs/heads/feature/something-2', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/something-2', + '/remote/repo-feature-something-2', + { base: 'origin/main' } + ) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts deleted file mode 100644 index fa98b5b62a16..000000000000 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getEffectiveHooksFromConfigMock, - parseOrcaYamlMock, - shouldRunSetupForCreateMock, - resolveSetupRunnerShellMock, - getSshGitProviderMock, - getSshFilesystemProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--git-path') { - return { - stdout: '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh\n', - stderr: '' - } - } - if (args[0] === 'rev-parse') { - throw new Error('missing local branch') - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/old-improve-dashboard', - head: 'old123', - branch: 'refs/heads/archive/improve-dashboard', - isBare: false, - isMainWorktree: false - }, - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n', - isBinary: false - }), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - parseOrcaYamlMock.mockReturnValue({ - scripts: { setup: 'pnpm install' }, - setupAgentStartupPolicy: 'wait-for-setup' - }) - getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - shouldRunSetupForCreateMock.mockReturnValue(true) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard', - setupDecision: 'run' - }) - - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo-improve-dashboard/orca.yaml') - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/setup-runner.sh'], - '/remote/repo-improve-dashboard' - ) - expect(fsProvider.createDir).toHaveBeenCalledWith( - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', - '#!/usr/bin/env bash\nset -e\npnpm install\n' - ) - expect(result).toEqual( - expect.objectContaining({ - setup: { - runnerScriptPath: - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', - envVars: expect.objectContaining({ - ORCA_ROOT_PATH: '/remote/repo', - ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard' - }), - waitForAgentStartup: true - } - }) - ) - }) - - it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { - const repo = { - id: 'repo-ssh', - path: 'C:\\remote\\repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--git-path') { - return { - stdout: - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', - stderr: '' - } - } - if (args[0] === 'rev-parse') { - throw new Error('missing local branch') - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: 'C:\\remote\\improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n setup: pnpm install\n', - isBinary: false - }), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getSettings.mockReturnValue({ - branchPrefix: 'none', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - terminalWindowsShell: 'git-bash', - workspaceDir: 'C:\\workspace' - }) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - shouldRunSetupForCreateMock.mockReturnValue(true) - resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard', - setupDecision: 'run' - }) - - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], - 'C:\\remote\\improve-dashboard' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', - 'pnpm install' - ) - expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() - expect(result).toEqual( - expect.objectContaining({ - setup: { - runnerScriptPath: - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', - envVars: expect.objectContaining({ - ORCA_ROOT_PATH: 'C:\\remote\\repo', - ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' - }) - } - }) - ) - }) - - it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-sparse-dashboard', - head: 'abc123', - branch: 'refs/heads/sparse-dashboard', - isBare: false, - isSparse: true, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getSparsePresets.mockReturnValue([ - { - id: 'preset-1', - repoId: 'repo-ssh', - name: 'App', - directories: ['apps/mobile', 'packages/shared'], - createdAt: 1, - updatedAt: 1 - } - ]) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'sparse-dashboard', - sparseCheckout: { - directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'], - presetId: 'preset-1' - } - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'sparse-dashboard', - '/remote/repo-sparse-dashboard', - { base: 'origin/main', noCheckout: true } - ) - expect(provider.exec).toHaveBeenCalledWith( - ['sparse-checkout', 'init', '--cone'], - '/remote/repo-sparse-dashboard' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], - '/remote/repo-sparse-dashboard' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['checkout', 'sparse-dashboard'], - '/remote/repo-sparse-dashboard' - ) - expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-ssh::/remote/repo-sparse-dashboard', - expect.objectContaining({ - sparseDirectories: ['apps/mobile', 'packages/shared'], - baseRef: 'refs/remotes/origin/main', - sparseBaseRef: 'refs/remotes/origin/main', - sparsePresetId: 'preset-1' - }) - ) - expect(result).toMatchObject({ - worktree: expect.objectContaining({ - isSparse: true, - sparseDirectories: ['apps/mobile', 'packages/shared'], - sparseBaseRef: 'refs/remotes/origin/main', - sparsePresetId: 'preset-1' - }) - }) - }) -}) diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index f7b092813e5f..abfe38dbacb2 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' import { isPlainObject } from '../agent-hooks/installer-utils' import { KiroHookService } from './hook-service' import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings' diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.test.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.test.ts new file mode 100644 index 000000000000..895ad880e634 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { JournalItemAppender } from './journal-item-appender' +import { createJournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const OPTIONS = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +describe('a resolved append that finds nothing', () => { + // A shared module-level Error would pin the bundle's startup frames for the whole session. + it('never reuses one thrown object across appends', async () => { + const thrown: unknown[] = [] + const appender = new JournalItemAppender({ + state: () => createJournalReducerState('session-1', 'epoch-1'), + enqueue: (build: (seq: number, ts: number) => JournalRow) => { + try { + return Promise.resolve(build(1, 0)) + } catch (error) { + thrown.push(error) + return Promise.reject(error) + } + } + }) + + await expect(appender.appendResolved(() => null, OPTIONS)).resolves.toBeNull() + await expect(appender.appendResolved(() => null, OPTIONS)).resolves.toBeNull() + expect(new Set(thrown).size).toBe(thrown.length) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 146078d830c9..1faec275ffd7 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -8,7 +8,8 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalAppendResult, JournalItemAppendOptions } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' -const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') +// A module-level Error's lazy stack retains the bundle's startup code and read buffer. +class NothingResolvedError extends Error {} export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } @@ -41,7 +42,7 @@ export class JournalItemAppender { .enqueue((seq, ts) => { const resolved = resolve() if (resolved === null) { - throw NOTHING_RESOLVED + throw new NothingResolvedError('journal_item_resolved_to_nothing') } itemId = agentJournalItemKey(resolved.identity) return journalItemRowBuilder( @@ -54,7 +55,7 @@ export class JournalItemAppender { .then( (row) => appendResult(row, itemId), (error: unknown) => { - if (error === NOTHING_RESOLVED) { + if (error instanceof NothingResolvedError) { return null } throw error diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 47764033180f..69e4dd22f007 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -110,6 +110,31 @@ describe('when a journal write lands', () => { await inner expect(sequenceOf(journal, 2)).toBe(sequenceOf(journal, 1)! + 1) }) + + it('writes no row for a resolve that finds nothing, and keeps the line moving', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + + await expect(journal.appendResolvedItem(() => null, OPTIONS)).resolves.toBeNull() + expect(journal.cursor().sequence).toBe(before) + await journal.appendItem(item(1), reply('after'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) + }) + + it('preserves other resolver errors even when their message matches the no-row error', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + const error = new Error('journal_item_resolved_to_nothing') + + await expect( + journal.appendResolvedItem(() => { + throw error + }, OPTIONS) + ).rejects.toBe(error) + expect(journal.cursor().sequence).toBe(before) + await journal.appendItem(item(1), reply('after'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) + }) }) describe('the journal write queue', () => { diff --git a/src/main/native-chat/transcript-opencode-database.test.ts b/src/main/native-chat/transcript-opencode-database.test.ts index daaeca3b6856..165d3ac20e46 100644 --- a/src/main/native-chat/transcript-opencode-database.test.ts +++ b/src/main/native-chat/transcript-opencode-database.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' +import { homedir } from 'node:os' import type { OpenCodeTranscriptSignal } from './transcript-opencode-sqlite-query' import { WslTranscriptFsError } from './wsl-transcript-fs-error' @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ ), prepare: vi.fn(async () => []), configure: vi.fn(), + access: vi.fn(async () => true), wslPath: vi.fn((_path: string): { distro: string; linuxPath: string } | null => null), readSignal: vi.fn( async ( @@ -23,6 +25,7 @@ const mocks = vi.hoisted(() => ({ ) })) vi.mock('../ai-vault/cached-session-list', () => ({ getAiVaultWslHomeDirs: mocks.homes })) +vi.mock('./wsl-transcript-fs-access', () => ({ wslGatedAccess: mocks.access })) vi.mock('../opencode-usage/opencode-database-discovery', () => ({ listOpenCodeDatabases: mocks.native, listOpenCodeDatabasesInDirectory: mocks.directory, @@ -50,6 +53,54 @@ afterEach(() => { }) describe('OpenCode transcript owning-host database discovery', () => { + it('reports a missing ZCode install as unavailable without a SQLite refusal', async () => { + mocks.access.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + await expect( + openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode') + ).resolves.toBeNull() + expect(mocks.readSignal).not.toHaveBeenCalled() + }) + it('opens a native ZCode session before an unresolved WSL lookup', async () => { + vi.useFakeTimers() + mocks.homes.mockImplementation(() => new Promise(() => {})) + mocks.readSignal.mockResolvedValue({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + }) + const settled = vi.fn() + void openCodeTranscriptDefaultDeps + .resolveDbPath('z-session', undefined, 'zcode') + .then(settled, settled) + await vi.advanceTimersByTimeAsync(50) + expect(settled).toHaveBeenCalledWith(join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite')) + expect(mocks.homes).not.toHaveBeenCalled() + expect(mocks.native).not.toHaveBeenCalled() + }) + + it('finds a WSL ZCode session without probing OpenCode databases', async () => { + const home = 'wsl-home' + const dbPath = join(home, '.zcode', 'cli', 'db', 'db.sqlite') + mocks.homes.mockResolvedValue([home]) + mocks.readSignal.mockResolvedValueOnce(null).mockResolvedValueOnce({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + }) + await expect( + openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode') + ).resolves.toBe(dbPath) + expect(mocks.prepare).toHaveBeenCalledWith([home]) + expect(mocks.readSignal).toHaveBeenLastCalledWith( + { dbPath, sessionId: 'z-session', agent: 'zcode' }, + expect.any(AbortSignal) + ) + expect(mocks.native).not.toHaveBeenCalled() + expect(mocks.directory).not.toHaveBeenCalled() + }) + it.each(['homes', 'directory', 'prepare'] as const)( 'opens a matching native database without waiting for WSL %s', async (stage) => { diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts index 92b97e7c9b09..2a9ad947b141 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -1,3 +1,5 @@ +import { homedir } from 'node:os' +import { wslGatedAccess } from './wsl-transcript-fs-access' import { join } from 'node:path' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' @@ -13,9 +15,15 @@ import { listOpenCodeDatabasesInDirectory } from '../opencode-usage/opencode-database-discovery' +// ZCode sessions belong only to its own store. +function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] { + return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) +} + export async function discoverOpenCodeTranscriptDatabase( sessionId?: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { signal?.throwIfAborted() const deadline = new AbortController() @@ -41,13 +49,25 @@ export async function discoverOpenCodeTranscriptDatabase( } boundedSignal.throwIfAborted() probed.add(dbPath) - if (!sessionId) { - return dbPath - } try { + if ( + agent === 'zcode' && + !(await waitForPromiseWithSignal( + wslGatedAccess(dbPath, 'exact', boundedSignal), + boundedSignal + )) + ) { + continue + } + if (!sessionId) { + return dbPath + } if ( await waitForPromiseWithSignal( - readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal), + readOpenCodeTranscriptSignalViaWorker( + { dbPath, sessionId, ...(agent ? { agent } : {}) }, + boundedSignal + ), boundedSignal ) ) { @@ -55,12 +75,46 @@ export async function discoverOpenCodeTranscriptDatabase( } } catch (error) { boundedSignal.throwIfAborted() + if ( + agent === 'zcode' && + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + continue + } refusals.push(error instanceof Error ? error : new Error(String(error))) } } return null } try { + if (agent === 'zcode') { + const native = await findSession(zcodeTranscriptDatabasePaths([])) + if (native) { + return native + } + const homes = (await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)).slice( + 0, + 32 + ) + if (homes.length > 0) { + const readers = await waitForPromiseWithSignal( + prepareOpenCodeWslReaders(homes), + boundedSignal + ) + boundedSignal.throwIfAborted() + configureOpenCodeWslReaders(readers) + } + const wsl = await findSession(zcodeTranscriptDatabasePaths(homes)) + if (wsl) { + return wsl + } + if (refusals[0]) { + throw refusals[0] + } + return null + } const primary = await waitForPromiseWithSignal( listOpenCodeDatabases(onRefusal, undefined, boundedSignal), boundedSignal diff --git a/src/main/native-chat/transcript-opencode-fingerprint-window.ts b/src/main/native-chat/transcript-opencode-fingerprint-window.ts new file mode 100644 index 000000000000..baa60be503cf --- /dev/null +++ b/src/main/native-chat/transcript-opencode-fingerprint-window.ts @@ -0,0 +1,17 @@ +export function pruneOpenCodeFingerprintCache( + fingerprints: Map, + cap: number, + agent?: 'zcode' +): void { + if (fingerprints.size <= cap) { + return + } + const keys = [...fingerprints.keys()] + const newest = agent === 'zcode' ? keys.toReversed() : keys.sort((a, b) => b - a) + const keep = new Set(newest.slice(0, cap)) + for (const rowid of fingerprints.keys()) { + if (!keep.has(rowid)) { + fingerprints.delete(rowid) + } + } +} diff --git a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts index aa30a4b743a5..94d21df4b450 100644 --- a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts +++ b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts @@ -177,12 +177,14 @@ describe.each(['v1', 'v2'] as const)('%s semantic pagination', (version) => { }) it('reconstructs a full read beyond its 500-message window without losing a split row', async () => { - const { path, insert, prefix } = fixture(version) + const { db, path, insert, prefix } = fixture(version) const expected: string[] = [] + db.exec('BEGIN') for (let index = 0; index < 260; index++) { insert(String(index), mixed()) expected.push(`${prefix}${index}:reasoning`, `${prefix}${index}`) } + db.exec('COMMIT') const result = await readOpenCodeNativeChatTranscriptFull('session', { resolveDbPath: async () => path, readPage: async (args) => readOpenCodeTranscriptPage(args) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index d245020b619a..99b1c0d1577b 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -1,4 +1,6 @@ import { openOpenCodeDatabaseReadonly } from '../ai-vault/session-scanner-opencode-sqlite-open' +import { zcodeVisibleMessageFilter } from '../ai-vault/session-scanner-zcode-visibility' +import { zcodeTranscriptOrder } from '../ai-vault/session-scanner-zcode-order' import { readOpenCode2TranscriptPage, readOpenCode2TranscriptSignal @@ -70,7 +72,8 @@ function rowsWithinBudget( export function readOpenCodeTranscriptSignal( dbPath: string, - sessionId: string + sessionId: string, + agent?: 'zcode' ): OpenCodeTranscriptSignal | null { const db = openOpenCodeDatabaseReadonly(dbPath) try { @@ -82,23 +85,41 @@ export function readOpenCodeTranscriptSignal( return null } // Aggregates without GROUP BY always yield exactly one row, so [0] is it. - const [messageRow] = rowsOf<{ message_count: number; max_message_rowid: number }>( + const visibility = zcodeVisibleMessageFilter(agent ?? 'opencode', 'data') + const [messageRow] = rowsOf<{ + message_count: number + max_message_rowid: number + max_message_time_updated: number + }>( db.prepare( - 'SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ?' + `SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid, + COALESCE(MAX(time_updated), 0) AS max_message_time_updated + FROM message WHERE session_id = ? ${visibility}` ), sessionId ) const [partRow] = rowsOf<{ part_count: number; max_part_time_updated: number }>( db.prepare( - 'SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated FROM part WHERE session_id = ?' + `SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated + FROM part WHERE session_id = ? ${ + agent === 'zcode' + ? `AND message_id IN ( + SELECT id FROM message WHERE session_id = ? ${visibility} + )` + : '' + }` ), - sessionId + sessionId, + ...(agent === 'zcode' ? [sessionId] : []) ) return { messageCount: messageRow?.message_count ?? 0, partCount: partRow?.part_count ?? 0, maxMessageRowId: messageRow?.max_message_rowid ?? 0, - maxPartTimeUpdated: partRow?.max_part_time_updated ?? 0 + maxPartTimeUpdated: Math.max( + partRow?.max_part_time_updated ?? 0, + agent === 'zcode' ? (messageRow?.max_message_time_updated ?? 0) : 0 + ) } } finally { db.close() @@ -110,6 +131,7 @@ export function readOpenCodeTranscriptPage(args: { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }): OpenCodeTranscriptPage | null { const db = openOpenCodeDatabaseReadonly(args.dbPath) try { @@ -121,14 +143,33 @@ export function readOpenCodeTranscriptPage(args: { return null } const limit = openCodeTranscriptPageLimit(args.limit) + // Rank only inside the query; wire cursors retain the stable storage row identity. + const ranked = args.agent === 'zcode' + const orderColumn = ranked ? 'ordered.transcript_order' : 'm.rowid' + if ( + ranked && + args.beforeMessageRowId !== undefined && + !db + .prepare('SELECT 1 FROM message WHERE session_id = ? AND rowid = ?') + .get(args.sessionId, args.beforeMessageRowId) + ) { + throw new Error('Transcript pagination cursor was removed; reload the conversation') + } + const ordered = ranked + ? `WITH ordered AS ( + SELECT m.rowid AS storage_rowid, ROW_NUMBER() OVER ( + ORDER BY ${zcodeTranscriptOrder(db, 'zcode', 'message', 'm', 'ASC')} + ) AS transcript_order FROM message m WHERE session_id = ? + )` + : '' // The upper bound is always bound: batching advances the cursor mid-page, // so the statement cannot vary with `beforeMessageRowId`'s presence. // MAX_SAFE_INTEGER is the "from the newest row" sentinel. const select = db.prepare( - `SELECT rowid AS message_rowid, id, time_created, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data - FROM message - WHERE session_id = ? AND rowid < ? - ORDER BY rowid DESC + `${ordered} SELECT m.rowid AS message_rowid, ${orderColumn} AS transcript_order, m.id, m.time_created, m.time_updated, CASE WHEN length(CAST(m.data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN m.data ELSE NULL END AS data + FROM message m ${ranked ? 'JOIN ordered ON ordered.storage_rowid = m.rowid' : ''} + WHERE m.session_id = ? AND ${orderColumn} < ${ranked ? 'COALESCE((SELECT transcript_order FROM ordered WHERE storage_rowid = ?), 9007199254740991)' : '?'} ${zcodeVisibleMessageFilter(args.agent ?? 'opencode')} + ORDER BY ${orderColumn} DESC LIMIT ?` ) const collected: OpenCodeTranscriptItem[] = [] @@ -141,6 +182,7 @@ export function readOpenCodeTranscriptPage(args: { const rows = rowsWithinBudget( select, rawBudget, + ...(ranked ? [args.sessionId] : []), args.sessionId, cursor ?? Number.MAX_SAFE_INTEGER, limit + 1 @@ -156,7 +198,7 @@ export function readOpenCodeTranscriptPage(args: { hasMore = rows.length > limit const selected = hasMore ? rows.slice(0, limit) : rows cursor = selected.at(-1)!.message_rowid - const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget) + const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget, args.agent) pageBytes += Buffer.byteLength(JSON.stringify(mapped)) if (pageBytes > 16 * 1024 * 1024) { throw new Error('OpenCode transcript page exceeds its byte limit') @@ -189,6 +231,7 @@ export function readOpenCodeTranscriptPage(args: { type MessageRow = { message_rowid: number + transcript_order: number id: string time_created: number time_updated: number @@ -205,7 +248,8 @@ function mapMessageRows( db: SyncDatabase, sessionId: string, rows: MessageRow[], - rawBudget: { bytes: number } + rawBudget: { bytes: number }, + agent?: 'zcode' ): OpenCodeTranscriptItem[] { if (rows.length === 0) { return [] @@ -217,9 +261,9 @@ function mapMessageRows( const placeholders = batch.map(() => '?').join(', ') const partRows = rowsWithinBudget( db.prepare( - `SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part + `SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part p WHERE session_id = ? AND message_id IN (${placeholders}) - ORDER BY rowid LIMIT 10001` + ORDER BY ${agent === 'zcode' ? zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC') : 'rowid'} LIMIT 10001` ), rawBudget, sessionId, @@ -256,7 +300,11 @@ function mapMessageRows( items.push({ rowid: row.message_rowid, fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, - message: { ...message, transcriptOffset: row.message_rowid } + message: { + ...message, + transcriptOffset: row.message_rowid, + ...(agent === 'zcode' ? { transcriptOrder: row.transcript_order } : {}) + } }) } } diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts index cce4b243cf8a..caa7e574a088 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.test.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -11,6 +11,24 @@ import { import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' const fixtures: { db: Database.Database; root: string; stop: () => void }[] = [] +it.each([undefined, 'zcode'] as const)( + 'discovers the subscribed session in its own store (%s)', + async (agent) => { + const resolveDbPath = vi.fn(async () => null) + const subscription = subscribeOpenCodeNativeChatTranscript( + { agent: agent ?? 'opencode', sessionId: 'second-database-session', onAppend: vi.fn() }, + undefined, + { resolveDbPath }, + agent + ) + subscription.unsubscribe() + expect(resolveDbPath).toHaveBeenCalledWith( + 'second-database-session', + expect.any(AbortSignal), + agent + ) + } +) afterEach(() => { for (const { db, root, stop } of fixtures.splice(0)) { stop() diff --git a/src/main/native-chat/transcript-opencode-subscribe.ts b/src/main/native-chat/transcript-opencode-subscribe.ts index b5faaf884ca3..637218d04ee4 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.ts @@ -1,3 +1,5 @@ +import { pruneOpenCodeFingerprintCache } from './transcript-opencode-fingerprint-window' +export { pruneOpenCodeFingerprintCache as pruneOpenCodeFingerprintCacheForTest } from './transcript-opencode-fingerprint-window' import { errorMessage } from '../ai-vault/session-scanner-values' import { DESKTOP_READ_WINDOW, @@ -14,40 +16,21 @@ import { const OPENCODE_POLL_MS = 1_000 -function pruneOpenCodeFingerprintCache(fingerprints: Map, cap: number): void { - if (fingerprints.size <= cap) { - return - } - const keep = new Set([...fingerprints.keys()].sort((a, b) => b - a).slice(0, cap)) - for (const rowid of fingerprints.keys()) { - if (!keep.has(rowid)) { - fingerprints.delete(rowid) - } - } -} - -export const pruneOpenCodeFingerprintCacheForTest = pruneOpenCodeFingerprintCache - export function subscribeOpenCodeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs, setupSignal?: AbortSignal, - deps: OpenCodeTranscriptDeps = {} + deps: OpenCodeTranscriptDeps = {}, + agent?: 'zcode' ): NativeChatTranscriptSubscription { setupSignal?.throwIfAborted() const controller = new AbortController() - const resolveDbPath = () => - (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( - args.sessionId, - controller.signal - ) - const readSignal = (dbPath: string, sessionId: string) => - (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)( - dbPath, - sessionId, + const resolveDbPath = deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath + const readSignal = deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal + const readPage = (page: Parameters>[0]) => + (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)( + { ...page, ...(agent ? { agent } : {}) }, controller.signal ) - const readPage = (page: Parameters>[0]) => - (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(page, controller.signal) const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS const initialLimit = openCodeTranscriptPageLimit( args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW @@ -117,7 +100,7 @@ export function subscribeOpenCodeNativeChatTranscript( return } try { - dbPath ??= await resolveDbPath() + dbPath ??= await resolveDbPath(args.sessionId, controller.signal, agent) if (closed) { return } @@ -126,7 +109,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const signal = await readSignal(dbPath, args.sessionId) + const signal = await readSignal(dbPath, args.sessionId, controller.signal, agent) if (closed) { return } @@ -175,6 +158,17 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } + // Imported rows can precede the frontier regardless of their storage rowid. + if (agent === 'zcode') { + if ( + await replaceWithBridgedWindow(dbPath, page, signal.messageCount < lastCounts.messages) + ) { + lastSignal = fingerprint + lastCounts = { messages: signal.messageCount, parts: signal.partCount } + } + scheduleTick() + return + } let changed = page.items.some( (item) => item.rowid <= lastEmittedRowId && fingerprints.get(item.rowid) !== item.fingerprint @@ -256,7 +250,7 @@ export function subscribeOpenCodeNativeChatTranscript( lastEmittedRowId = item.rowid } } - pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW) + pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW, agent) windowLimit = Math.max(windowLimit, fingerprints.size) } @@ -293,7 +287,11 @@ export function subscribeOpenCodeNativeChatTranscript( emitSafely(() => args.onReplace?.([], hasMore, before), 'replace') return true } - if (shrinking || oldest <= lastEmittedRowId || !replacement.hasMore) { + const overlaps = + agent === 'zcode' + ? replacement.items.some((item) => fingerprints.has(item.rowid)) + : oldest <= lastEmittedRowId + if (shrinking || overlaps || !replacement.hasMore) { fingerprints.clear() lastEmittedRowId = 0 rememberItems(replacement.items) diff --git a/src/main/native-chat/transcript-opencode.test.ts b/src/main/native-chat/transcript-opencode.test.ts index 6cf17a8cb75d..7b49dcac3f26 100644 --- a/src/main/native-chat/transcript-opencode.test.ts +++ b/src/main/native-chat/transcript-opencode.test.ts @@ -66,6 +66,22 @@ describe('readOpenCodeNativeChatTranscriptFull', () => { expect(result).toEqual({ error: 'Transcript unavailable', notFound: true }) }) + it('reads the selected ZCode store on every full-read page', async () => { + const resolveDbPath = vi.fn(async () => '/fake/zcode.db') + const readPage = vi.fn(async () => ({ items: [], hasMore: false, beforeMessageRowId: null })) + await readOpenCodeNativeChatTranscriptFull( + 'z-session', + { resolveDbPath, readPage }, + undefined, + 'zcode' + ) + expect(resolveDbPath).toHaveBeenCalledWith('z-session', undefined, 'zcode') + expect(readPage).toHaveBeenCalledWith( + { dbPath: '/fake/zcode.db', sessionId: 'z-session', limit: 500, agent: 'zcode' }, + undefined + ) + }) + it('returns a retryable error when DB discovery throws', async () => { const result = await readOpenCodeNativeChatTranscriptFull('ses-1', { resolveDbPath: async () => { diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts index 497635de87e1..93f14b3bbc63 100644 --- a/src/main/native-chat/transcript-opencode.ts +++ b/src/main/native-chat/transcript-opencode.ts @@ -20,11 +20,16 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa // OpenCode SQLite reads use the same bounded worker as AI Vault. export type OpenCodeTranscriptDeps = { - resolveDbPath?: (sessionId?: string, signal?: AbortSignal) => Promise + resolveDbPath?: ( + sessionId?: string, + signal?: AbortSignal, + agent?: 'zcode' + ) => Promise readSignal?: ( dbPath: string, sessionId: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ) => Promise readPage?: ( args: { @@ -32,6 +37,7 @@ export type OpenCodeTranscriptDeps = { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) => Promise @@ -43,8 +49,8 @@ export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise = { resolveDbPath: discoverOpenCodeTranscriptDatabase, - readSignal: (dbPath, sessionId, signal) => - readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal), + readSignal: (dbPath, sessionId, signal, agent) => + readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId, agent }, signal), readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal) } @@ -53,7 +59,7 @@ export type OpenCodeTailResult = | { error: string; notFound?: true } export async function readOpenCodeNativeChatTranscriptTail( - args: { sessionId: string; limit: number; beforeOffset?: number }, + args: { sessionId: string; limit: number; beforeOffset?: number; agent?: 'zcode' }, deps: OpenCodeTranscriptDeps = {}, signal?: AbortSignal ): Promise { @@ -61,7 +67,8 @@ export async function readOpenCodeNativeChatTranscriptTail( try { const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( args.sessionId, - signal + signal, + args.agent ) if (!dbPath) { return { error: 'Transcript unavailable', notFound: true } @@ -71,7 +78,8 @@ export async function readOpenCodeNativeChatTranscriptTail( dbPath, sessionId: args.sessionId, limit, - ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}) + ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}), + ...(args.agent ? { agent: args.agent } : {}) }, signal ) @@ -91,7 +99,8 @@ export async function readOpenCodeNativeChatTranscriptTail( export async function readOpenCodeNativeChatTranscriptFull( sessionId: string, deps: OpenCodeTranscriptDeps = {}, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { // Pages arrive newest-window first; reverse windows, preserving message order. const pages: NativeChatMessage[][] = [] @@ -101,7 +110,8 @@ export async function readOpenCodeNativeChatTranscriptFull( try { const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( sessionId, - signal + signal, + agent ) if (!dbPath) { return { error: 'Transcript unavailable', notFound: true } @@ -113,7 +123,8 @@ export async function readOpenCodeNativeChatTranscriptFull( dbPath, sessionId, limit: 500, - ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}) + ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}), + ...(agent ? { agent } : {}) }, signal ) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 7233f001de48..25072906d17b 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -107,7 +107,12 @@ export async function readNativeChatTranscriptCached( transcriptPath?: string ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } const epoch = cacheEpoch let filePath: string | null diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 6cad15a7757f..b90b8681b445 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -45,7 +45,12 @@ export async function readNativeChatTranscript( options: ReadTranscriptOptions = {} ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } let filePath: string | null try { diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index fb44470047bf..fce4b630e4e8 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -246,7 +246,11 @@ export async function readNativeChatTranscriptTail( | { error: string; notFound?: true } > { if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptTail(args, {}, signal) + return readOpenCodeNativeChatTranscriptTail( + { ...args, agent: args.agent === 'zcode' ? 'zcode' : undefined }, + {}, + signal + ) } const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index a5090b4c8008..69bb21e8c714 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -267,7 +267,12 @@ export async function subscribeNativeChatTranscript( ): Promise { setupSignal?.throwIfAborted() if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return subscribeOpenCodeNativeChatTranscript(args, setupSignal) + return subscribeOpenCodeNativeChatTranscript( + args, + setupSignal, + {}, + args.agent === 'zcode' ? 'zcode' : undefined + ) } const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { diff --git a/src/main/native-chat/transcript-zcode.test.ts b/src/main/native-chat/transcript-zcode.test.ts new file mode 100644 index 000000000000..31fcc45b5b75 --- /dev/null +++ b/src/main/native-chat/transcript-zcode.test.ts @@ -0,0 +1,254 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import Database from '../sqlite/sync-database' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { + readOpenCodeNativeChatTranscriptFull, + readOpenCodeNativeChatTranscriptTail +} from './transcript-opencode' +import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' +import { handleOpenCodeSqliteRequest } from '../ai-vault/session-scanner-opencode-sqlite-dispatch' + +const cleanups: (() => void)[] = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0).toReversed()) { + cleanup() + } + vi.useRealTimers() +}) + +function fixture(sequenced = true) { + const root = mkdtempSync(join(tmpdir(), 'orca-zcode-transcript-')) + const dbPath = join(root, 'db.sqlite') + const db = new Database(dbPath) + cleanups.push(() => { + db.close() + rmSync(root, { recursive: true, force: true }) + }) + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER${sequenced ? ', sequence INTEGER' : ''}); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER${sequenced ? ', sequence INTEGER' : ''}); + INSERT INTO session VALUES ('session'); + `) + const insert = (id: string, sequence: number | null, visibility?: string) => { + const values = [ + id, + 'session', + JSON.stringify({ role: 'user', semantics: { transcriptVisibility: visibility } }), + 1, + 1 + ] + db.prepare(`INSERT INTO message VALUES (?, ?, ?, ?, ?${sequenced ? ', ?' : ''})`).run( + ...values, + ...(sequenced ? [sequence] : []) + ) + db.prepare(`INSERT INTO part VALUES (?, ?, ?, ?, 1, 1${sequenced ? ', ?' : ''})`).run( + id, + id, + 'session', + JSON.stringify({ type: 'text', text: id }), + ...(sequenced ? [0] : []) + ) + } + const deps = { + resolveDbPath: vi.fn(async () => dbPath), + readSignal: async (path: string, sessionId: string, _signal?: AbortSignal, agent?: 'zcode') => + readOpenCodeTranscriptSignal(path, sessionId, agent), + readPage: async (args: Parameters[0]) => + readOpenCodeTranscriptPage(args) + } + return { db, dbPath, insert, deps } +} + +it.each([true, false])( + 'filters hidden messages through full, tail and worker reads (sequence=%s)', + async (sequenced) => { + const f = fixture(sequenced) + f.insert('legacy-visible', 0) + f.insert('hidden', 1, 'hidden') + f.insert('visible', 2, 'visible') + expect(readOpenCodeTranscriptSignal(f.dbPath, 'session', 'zcode')).toMatchObject({ + messageCount: 2, + partCount: 2 + }) + expect(readOpenCodeTranscriptSignal(f.dbPath, 'session')).toMatchObject({ + messageCount: 3, + partCount: 3 + }) + const full = await readOpenCodeNativeChatTranscriptFull('session', f.deps, undefined, 'zcode') + expect(full).toMatchObject({ messages: [{ id: 'legacy-visible' }, { id: 'visible' }] }) + const tail = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'session', limit: 1, agent: 'zcode' }, + f.deps + ) + expect(tail).toMatchObject({ messages: [{ id: 'visible' }], hasMore: true }) + const response = await handleOpenCodeSqliteRequest({ + id: 1, + kind: 'native-page', + dbPath: f.dbPath, + sessionId: 'session', + limit: 10, + agent: 'zcode' + }) + expect(response).toMatchObject({ + ok: true, + value: { items: [{ message: { id: 'legacy-visible' } }, { message: { id: 'visible' } }] } + }) + } +) + +it('paginates imported messages and parts by their recorded sequence', async () => { + const f = fixture() + f.insert('last', 2) + f.insert('first', 0) + f.insert('hidden', 1, 'hidden') + f.insert('second', 1) + f.insert('legacy-null', null) + f.db.prepare('UPDATE part SET sequence = 1 WHERE id = ?').run('second') + f.db + .prepare('INSERT INTO part VALUES (?, ?, ?, ?, 1, 1, 0)') + .run( + 'earlier-part', + 'second', + 'session', + JSON.stringify({ type: 'text', text: 'earlier part' }) + ) + const full = await readOpenCodeNativeChatTranscriptFull('session', f.deps, undefined, 'zcode') + expect(full).toMatchObject({ + messages: [ + { id: 'first' }, + { id: 'second', blocks: [{ text: 'earlier part' }, { text: 'second' }] }, + { id: 'last' }, + { id: 'legacy-null' } + ] + }) + const collected: string[] = [] + let cursor: number | undefined + for (let index = 0; index < 5; index++) { + const page = readOpenCodeTranscriptPage({ + dbPath: f.dbPath, + sessionId: 'session', + limit: 1, + agent: 'zcode', + beforeMessageRowId: cursor + }) + collected.unshift(...(page?.items.map((item) => item.message.id) ?? [])) + if (!page?.hasMore) { + break + } + cursor = page.beforeMessageRowId ?? undefined + } + expect(collected).toEqual(['first', 'second', 'last', 'legacy-null']) +}) + +it('reconciles visibility changes, ordered imports and deletions in a live chat', async () => { + vi.useFakeTimers() + const f = fixture() + f.insert('last', 2) + f.insert('first', 0) + f.insert('hidden', 1, 'hidden') + let displayed: NativeChatMessage[] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'zcode', + sessionId: 'session', + resolvePollIntervalMs: 5, + onInitialSnapshot: (messages) => { + displayed = messages + }, + onReplace: (messages) => { + displayed = messages + }, + onAppend: (messages) => { + displayed.push(...messages) + } + }, + undefined, + f.deps, + 'zcode' + ) + cleanups.push(subscription.unsubscribe) + await vi.advanceTimersByTimeAsync(0) + expect(displayed.map((message) => message.id)).toEqual(['first', 'last']) + f.db + .prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?') + .run(JSON.stringify({ role: 'user' }), 'hidden') + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['first', 'hidden', 'last']) + f.insert('middle', 1) + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['first', 'hidden', 'middle', 'last']) + f.db.prepare('DELETE FROM message WHERE id = ?').run('first') + f.db.prepare('DELETE FROM part WHERE message_id = ?').run('first') + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['hidden', 'middle', 'last']) +}) + +it('keeps a paging cursor stable when earlier messages are removed or imported', () => { + const f = fixture() + f.insert('last', 3) + f.insert('first', 0) + f.insert('middle', 2) + const newest = readOpenCodeTranscriptPage({ + dbPath: f.dbPath, + sessionId: 'session', + limit: 1, + agent: 'zcode' + }) + f.db.prepare('DELETE FROM message WHERE id = ?').run('first') + f.db.prepare('DELETE FROM part WHERE message_id = ?').run('first') + f.insert('imported', 1) + const older = readOpenCodeTranscriptPage({ + dbPath: f.dbPath, + sessionId: 'session', + limit: 10, + agent: 'zcode', + beforeMessageRowId: newest?.beforeMessageRowId ?? undefined + }) + expect(older?.items.map((item) => item.message.id)).toEqual(['imported', 'middle']) +}) + +it('observes a balanced visibility swap even when part counts and times stay unchanged', async () => { + vi.useFakeTimers() + const f = fixture() + f.insert('first', 0) + f.insert('hidden', 1, 'hidden') + f.insert('last', 2) + let displayed: NativeChatMessage[] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'zcode', + sessionId: 'session', + resolvePollIntervalMs: 5, + onInitialSnapshot: (messages) => { + displayed = messages + }, + onReplace: (messages) => { + displayed = messages + }, + onAppend: (messages) => { + displayed.push(...messages) + } + }, + undefined, + f.deps, + 'zcode' + ) + cleanups.push(subscription.unsubscribe) + await vi.advanceTimersByTimeAsync(0) + f.db + .prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?') + .run(JSON.stringify({ role: 'user', semantics: { transcriptVisibility: 'hidden' } }), 'first') + f.db + .prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?') + .run(JSON.stringify({ role: 'user' }), 'hidden') + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['hidden', 'last']) +}) diff --git a/src/main/orcad/orcad-cli-launcher.test.ts b/src/main/orcad/orcad-cli-launcher.test.ts index 3df5044d4583..6ae5efeb52d8 100644 --- a/src/main/orcad/orcad-cli-launcher.test.ts +++ b/src/main/orcad/orcad-cli-launcher.test.ts @@ -1,10 +1,17 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' -import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts' -import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME +} from '../../shared/orcad-artifacts' +import { + ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME, + getOrcadCliLauncherPath, + prepareOrcadCliLauncher +} from './orcad-cli-launcher' const roots = vi.hoisted(() => ({ install: '', profile: '' })) vi.mock('./orcad-app-paths', () => ({ @@ -87,10 +94,58 @@ describe('orcad profile CLI launcher', () => { expect(getOrcadCliLauncherPath()).toBeNull() }) - it('does not create a cmd.exe message proxy on Windows', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - await writeCli() - await prepareOrcadCliLauncher() - expect(getOrcadCliLauncherPath()).toBeNull() + describe('on a Windows host', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + async function writeLauncher(contents: string): Promise { + const path = join(roots.install, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, contents) + } + + const binDir = (): string => join(roots.profile, 'cli', 'bin') + const readConfig = (): Promise => + readFile(join(binDir(), ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME), 'utf8') + + it('installs the native launcher and points it at this slot, runtime and profile', async () => { + await writeCli() + await writeLauncher('native launcher v1') + await prepareOrcadCliLauncher() + const launcher = getOrcadCliLauncherPath() + expect(launcher).toBe(join(binDir(), 'orca.exe')) + expect(await readFile(launcher!, 'utf8')).toBe('native launcher v1') + expect(await readConfig()).toBe( + [ + `node=${process.execPath}`, + `cli=${join(roots.install, ...ORCAD_CLI_ENTRY_FILENAME.split('/'))}`, + `user-data=${roots.profile}`, + '' + ].join('\n') + ) + // cmd.exe reparses `%*`, so no batch or PowerShell proxy may shadow the exe. + expect((await readdir(binDir())).sort()).toEqual( + [ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME, 'orca.exe'].sort() + ) + }) + + it('refreshes the launcher and its target when the host switches server slots', async () => { + await writeCli() + await writeLauncher('native launcher v1') + await prepareOrcadCliLauncher() + roots.install = join(directory, 'updated server') + await writeCli() + await writeLauncher('native launcher v2') + await prepareOrcadCliLauncher() + expect(await readFile(join(binDir(), 'orca.exe'), 'utf8')).toBe('native launcher v2') + expect(await readConfig()).toContain(`cli=${join(roots.install, 'out', 'cli', 'index.js')}`) + }) + + it('leaves a slot without the native launcher with no orca command', async () => { + await writeCli() + await prepareOrcadCliLauncher() + expect(getOrcadCliLauncherPath()).toBeNull() + }) }) }) diff --git a/src/main/orcad/orcad-cli-launcher.ts b/src/main/orcad/orcad-cli-launcher.ts index 01660164e0a2..7bb351a37391 100644 --- a/src/main/orcad/orcad-cli-launcher.ts +++ b/src/main/orcad/orcad-cli-launcher.ts @@ -1,11 +1,20 @@ -import { chmod, mkdir, readFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { chmod, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts' +import { join } from 'node:path' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME +} from '../../shared/orcad-artifacts' +import { nodeFileContentsEqual } from '../../shared/node-file-content-equality' import { buildUnixCliLauncher } from '../cli/cli-dev-launcher' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths' +// Read by native/windows-cli-launcher: its presence beside orca.exe selects the server mode. +export const ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME = 'orca-launcher.cfg' +const RETIRED_LAUNCHER_SUFFIX = '.retired' + let launcherPath: string | null = null export function getOrcadCliLauncherPath(): string | null { @@ -14,19 +23,21 @@ export function getOrcadCliLauncherPath(): string | null { export async function prepareOrcadCliLauncher(): Promise { launcherPath = null - // Windows needs a native argv-preserving launcher; never proxy message bodies through cmd.exe. - if (process.platform === 'win32') { - return - } - const entry = join(resolveOrcadInstallRoot(), ...ORCAD_CLI_ENTRY_FILENAME.split('/')) + const installRoot = resolveOrcadInstallRoot() + const entry = join(installRoot, ...ORCAD_CLI_ENTRY_FILENAME.split('/')) // Older server slots and source-only runs may not include the CLI yet. if (!existsSync(entry)) { return } const userDataPath = resolveUserDataPath() - const path = join(userDataPath, 'cli', 'bin', 'orca') + const binDir = join(userDataPath, 'cli', 'bin') + if (process.platform === 'win32') { + launcherPath = await prepareWindowsLauncher(installRoot, entry, userDataPath, binDir) + return + } + const path = join(binDir, 'orca') const script = buildUnixCliLauncher(process.execPath, entry, userDataPath, 'node') - await mkdir(dirname(path), { recursive: true }) + await mkdir(binDir, { recursive: true }) const current = await readFile(path, 'utf8').catch(() => null) if (current !== script) { writeFileAtomically(path, script, { mode: 0o700 }) @@ -34,3 +45,53 @@ export async function prepareOrcadCliLauncher(): Promise { await chmod(path, 0o700) launcherPath = path } + +export function buildWindowsCliLauncherConfig( + nodePath: string, + cliEntryPath: string, + userDataPath: string +): string { + return `node=${nodePath}\ncli=${cliEntryPath}\nuser-data=${userDataPath}\n` +} + +/** A native exe, never a .cmd: cmd.exe reparses `%*` and cannot carry a multi-line body. */ +async function prepareWindowsLauncher( + installRoot: string, + entry: string, + userDataPath: string, + binDir: string +): Promise { + const source = join(installRoot, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + if (!existsSync(source)) { + return null + } + const path = join(binDir, 'orca.exe') + await mkdir(binDir, { recursive: true }) + await removeRetiredLaunchers(binDir) + const binary = await readFile(source) + if (!(await nodeFileContentsEqual(path, binary).catch(() => false))) { + try { + writeFileAtomically(path, binary) + } catch { + // A running `orca` keeps its image mapped; Windows still lets it be renamed away. + await rename(path, `${path}.${randomUUID()}${RETIRED_LAUNCHER_SUFFIX}`) + writeFileAtomically(path, binary) + } + } + const configPath = join(binDir, ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME) + const config = buildWindowsCliLauncherConfig(process.execPath, entry, userDataPath) + if ((await readFile(configPath, 'utf8').catch(() => null)) !== config) { + writeFileAtomically(configPath, config) + } + return path +} + +async function removeRetiredLaunchers(binDir: string): Promise { + const names = await readdir(binDir).catch(() => []) + await Promise.all( + names + .filter((name) => name.startsWith('orca.exe.') && name.endsWith(RETIRED_LAUNCHER_SUFFIX)) + // One still running stays until a later start. + .map((name) => rm(join(binDir, name), { force: true }).catch(() => {})) + ) +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 67f4cebe7ace..1d46f46f9c42 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -113,6 +113,7 @@ async function startOrcadRuntime( const { isAgentStatusHooksEnabled } = await import('../agent-hooks/managed-agent-hook-controls') const { installHookStatusSessionTabsRepublish } = await import('../agent-hooks/hook-status-session-tabs-republish') + const { startOrcadManagedAgentHooks } = await import('./orcad-managed-agent-hooks') const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = await import('../runtime/agent-status-observed-pane-identity') @@ -123,12 +124,10 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} - let removeStatusHookSettingsListener = (): void => {} // Cleanups run in reverse: RPC, then recovery and watchers, then the final flush, then daemon. registerCleanup(() => agentHookServer.stop()) registerCleanup(() => uninstallHookStatusRepublish()) registerCleanup(() => uninstallObservedStatusIdentity()) - registerCleanup(() => removeStatusHookSettingsListener()) // Why disconnect and not shut down: the daemon must outlive this process, or an orcad // restart goes back to killing every running terminal. registerCleanup(() => stopOrcadDaemon()) @@ -163,11 +162,11 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) - removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { - if ('agentStatusHooksEnabled' in updates) { - agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) - } - }) + startOrcadManagedAgentHooks( + profileStore, + (enabled) => agentHookServer.setStatusHooksEnabled(enabled), + registerCleanup + ) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-managed-agent-hooks.ts b/src/main/orcad/orcad-managed-agent-hooks.ts new file mode 100644 index 000000000000..dbdf08695fde --- /dev/null +++ b/src/main/orcad/orcad-managed-agent-hooks.ts @@ -0,0 +1,54 @@ +import { getAppEnvironment } from '../../shared/app-environment' +import { startCodexHooks } from '../codex/codex-hook-reconcile' +import { adoptLoginShellAgentHomes } from '../agent-hooks/agent-home-login-shell' +import { hydrateAgentCliShellPath } from '../agent-hooks/local-agent-cli-presence' +import { + isAgentStatusHooksEnabled, + isAgentStatusHooksEnabledForAgent +} from '../agent-hooks/managed-agent-hook-controls' +import { reconcileStartupManagedAgentHooks } from '../agent-hooks/managed-agent-hook-startup' +import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' +import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' + +/** + * Keeps this host's hook server and its agents' hook configs true to its settings. The startup + * reconcile is the desktop's: without it a freshly managed SSH host only got hooks when its hook + * settings changed, so its agents never reported status. + */ +export function startOrcadManagedAgentHooks( + store: Pick, + setStatusHooksEnabled: (enabled: boolean) => void, + registerCleanup: (cleanup: OrcadRuntimeCleanup) => void +): void { + let stopping = false + const removeSettingsListener = store.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) + registerCleanup(() => { + stopping = true + removeSettingsListener() + }) + const getSettings = () => store.getSettings() + const shouldHydrateShellPath = getAppEnvironment().isPackaged() + startCodexHooks({ + pathReady: shouldHydrateShellPath ? hydrateAgentCliShellPath() : Promise.resolve(), + isEnabled: () => isAgentStatusHooksEnabledForAgent(getSettings(), 'codex'), + // Why null: orcad launches Codex on ~/.codex; Orca-managed account homes are a desktop flow. + resolveLaunchHome: () => null + }) + void adoptLoginShellAgentHomes().then(() => + reconcileStartupManagedAgentHooks({ + getSettings, + isQuitting: () => stopping, + shouldHydrateShellPath + }) + ) + try { + refreshInstalledOpenCodeStatusPlugins(getSettings()) + } catch (error) { + console.warn('[orcad] could not refresh OpenCode status plugins:', error) + } +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 025dc6667736..b2349b560b19 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' +import type { GlobalSettings } from '../../shared/global-settings-types' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -21,7 +22,34 @@ const state = vi.hoisted(() => ({ startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), - send: vi.fn(async () => ({ ok: true, results: [] })) + send: vi.fn(async () => ({ ok: true, results: [] })), + startCodexHooks: vi.fn(), + installManagedAgentHooks: vi.fn(async () => []), + refreshOpenCodePlugins: vi.fn(), + adoptLoginShellAgentHomes: vi.fn(async () => {}) +})) +const profile = vi.hoisted(() => { + const value: { settings: Partial } = { settings: {} } + return value +}) +// Why: startup reconciles the user-global agent configs; keep it off this machine's real home. +vi.mock('../codex/codex-hook-reconcile', async (importOriginal) => ({ + ...(await importOriginal>()), + startCodexHooks: state.startCodexHooks +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', async (importOriginal) => ({ + ...(await importOriginal>()), + installManagedAgentHooks: state.installManagedAgentHooks +})) +vi.mock('../agent-hooks/local-agent-cli-presence', async (importOriginal) => ({ + ...(await importOriginal>()), + hydrateAgentCliShellPath: async () => {} +})) +vi.mock('../agent-hooks/agent-home-login-shell', () => ({ + adoptLoginShellAgentHomes: state.adoptLoginShellAgentHomes +})) +vi.mock('../opencode/opencode-status-plugin-startup-refresh', () => ({ + refreshInstalledOpenCodeStatusPlugins: state.refreshOpenCodePlugins })) vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => state.root, @@ -65,7 +93,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ } return { store: { - getSettings: () => ({}), + getSettings: () => profile.settings, onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} @@ -157,6 +185,7 @@ beforeEach(() => { afterEach(() => { rmSync(state.root, { recursive: true, force: true }) state.profileStartupErrors.length = 0 + profile.settings = {} vi.clearAllMocks() }) @@ -255,3 +284,42 @@ it('unsubscribes settings when daemon startup fails after hook setup', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +// Why: a freshly managed SSH host starts orcad on connect; without this its agents never report status. +it('installs agent status hooks on the host when it starts', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-hooks-')) + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + expect(state.startCodexHooks).toHaveBeenCalledOnce() + // orcad launches Codex on the real ~/.codex; it has no Orca-managed account homes. + expect(state.startCodexHooks.mock.calls[0]![0].resolveLaunchHome()).toBeNull() + await vi.waitFor(() => + expect(state.installManagedAgentHooks).toHaveBeenCalledWith( + profile.settings, + expect.objectContaining({ shouldHydrateShellPath: true }) + ) + ) + expect(state.refreshOpenCodePlugins).toHaveBeenCalledWith(profile.settings) + // Grok/Kiro homes set only in login profiles must be known before their installers run. + expect(state.adoptLoginShellAgentHomes.mock.invocationCallOrder[0]).toBeLessThan( + state.installManagedAgentHooks.mock.invocationCallOrder[0]! + ) + } finally { + await host.stop() + } +}) + +it('leaves agent configs alone when status hooks are off', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-hooks-off-')) + profile.settings = { agentStatusHooksEnabled: false } + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(state.installManagedAgentHooks).not.toHaveBeenCalled() + expect(state.startCodexHooks.mock.calls[0]![0].isEnabled()).toBe(false) + } finally { + await host.stop() + } +}) diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-runtime-admitted-terminal-membership.test.ts similarity index 55% rename from src/main/persistence-host-admitted-terminal-membership.test.ts rename to src/main/persistence-runtime-admitted-terminal-membership.test.ts index 83a6898c6671..82d748a70d04 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-runtime-admitted-terminal-membership.test.ts @@ -47,7 +47,23 @@ function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): st return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) } -describe('host-admitted terminal membership survives a stale renderer replay', () => { +/** `orca terminal create`: the runtime writes the pane, then binds the terminal it started. */ +async function startRuntimePane( + store: Awaited>, + pane: { worktreeId: string; tabId: string; leafId: string; ptyId: string; incarnationId?: string } +): Promise { + expect( + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }) + ).toBe('admitted') + return store.persistPtyBinding({ ...pane, mayCreate: false }) +} + +describe('runtime-admitted terminal membership survives a stale renderer replay', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-')) }) @@ -57,21 +73,26 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( rmSync(testState.dir, { recursive: true, force: true }) }) - it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => { + it('keeps the first runtime-admitted tab when the renderer replays its pre-create tab list', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - // `orca terminal create`: the host mints a tab the renderer has never seen. expect( - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) - expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') + const session = store.getWorkspaceSession() + expect(persistedTabIds(session, WORKTREE)).toContain('host-tab') + // Written with its tab-bar entry and group, so no view reads a row the tab bar lacks. + expect(session.unifiedTabs?.[WORKTREE]?.map((tab) => tab.entityId)).toContain('host-tab') + expect(session.tabGroups?.[WORKTREE]?.flatMap((group) => group.tabOrder)).toContain('host-tab') + expect(session.terminalLayoutsByTabId['host-tab']?.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_2]: 'host-pty' + }) // The renderer's debounced writer flushes a snapshot taken before the create. store.setWorkspaceSession(rendererSession()) @@ -79,16 +100,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') }) - it('keeps a host-admitted tab in a second worktree of the same repo', async () => { + it('keeps a runtime-admitted tab in a second worktree of the same repo', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: OTHER_WORKTREE, tabId: 'host-tab-other', leafId: TEST_LEAF_2, - ptyId: 'host-pty-other', - hostAdmittedMembership: true + ptyId: 'host-pty-other' }) store.setWorkspaceSession(rendererSession()) @@ -100,12 +120,11 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( store.setWorkspaceSession(rendererSession()) expect( - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe( @@ -118,9 +137,8 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( ) }) - // Polarity: without the flag the renderer still owns membership, so a renderer - // spawn racing its own writer must not freeze the tab list. - it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => { + // Polarity: a renderer spawn racing its own writer must not freeze the tab list. + it('leaves renderer-owned membership alone when the runtime did not admit the pane', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -138,16 +156,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( // Closing must still work afterwards. Closes are host-driven: the retirement is // computed from the store's own session (see stageTerminalSurfaceRetirements), // which is what outranks the fence this create just raised. - it('still lets the authoritative retirement path close the host-admitted tab', async () => { + it('still lets the authoritative retirement path close the runtime-admitted tab', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, ptyId: 'host-pty', - incarnationId: 'host-incarnation', - hostAdmittedMembership: true + incarnationId: 'host-incarnation' }) store.setWorkspaceSession( @@ -164,4 +181,76 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) }) + + it('refuses to bind a pane closed while its terminal started, and mints no tab for it', async () => { + const store = await createStore() + store.setWorkspaceSession(rendererSession()) + expect( + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2 + }) + ).toBe('admitted') + await store.withdrawTerminalPane({ + type: 'closePane', + workspace: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2 + }) + + expect( + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2, + ptyId: 'host-pty', + mayCreate: false + }) + ).toBe(false) + expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) + }) + + it('refuses to bind a split pane that moved to another tab while its terminal started', async () => { + const store = await createStore() + store.setWorkspaceSession(rendererSession()) + expect( + await store.admitTerminalPane({ + type: 'splitPane', + workspace: WORKTREE, + tabId: 'renderer-tab', + leafId: TEST_LEAF_1, + direction: 'vertical', + newLeafId: TEST_LEAF_2 + }) + ).toBe('admitted') + await expect( + store.moveTerminalLeafToNewTab({ + worktreeId: WORKTREE, + sourceTabId: 'renderer-tab', + targetTabId: 'moved-tab', + leafId: TEST_LEAF_2, + ptyId: null + }) + ).resolves.toMatchObject({ status: 'moved' }) + + expect( + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: 'renderer-tab', + leafId: TEST_LEAF_2, + ptyId: 'late-pty', + mayCreate: false + }) + ).toBe(false) + const session = store.getWorkspaceSession() + expect(session.terminalLayoutsByTabId['renderer-tab']?.root).toEqual({ + type: 'leaf', + leafId: TEST_LEAF_1 + }) + expect( + session.terminalLayoutsByTabId['moved-tab']?.ptyIdsByLeafId?.[TEST_LEAF_2] + ).toBeUndefined() + }) }) diff --git a/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts b/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts new file mode 100644 index 000000000000..e3f9e215c0a7 --- /dev/null +++ b/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + closeTestStores, + createStore, + testState, + writeDataFile +} from '../../persistence-test-harness' +import { getDefaultPersistedState } from '../../../shared/constants' +import { updateSettings, type SettingsMutationOperations } from './settings-update' + +function makeOperations(): SettingsMutationOperations { + const state = getDefaultPersistedState(tmpdir()) + state.settings.terminalLinkActionPopoverEnabled = false + return { + state, + bumpLocalWorktreeScanGeneration: vi.fn(), + removeRetainedBlob: vi.fn(), + scheduleSave: vi.fn(), + notifySettingsChanged: vi.fn() + } +} + +describe('terminal URL click preference writes', () => { + it.each(['actions', 'open', 'none'] as const)( + 'publishes the explicit %s choice with a consistent legacy switch', + (behavior) => { + const operations = makeOperations() + operations.state.settings.terminalLinkActionPopoverEnabled = behavior !== 'actions' + operations.state.settings.terminalLinkClickBehavior = + behavior === 'actions' ? 'none' : 'actions' + updateSettings( + operations, + { + terminalLinkClickBehavior: behavior, + terminalLinkActionPopoverEnabled: behavior !== 'actions' + }, + { notifyListeners: true, originWebContentsId: 7 } + ) + + expect(operations.state.settings.terminalLinkClickBehavior).toBe(behavior) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe( + behavior === 'actions' + ) + expect(operations.notifySettingsChanged).toHaveBeenCalledWith( + expect.objectContaining({ + terminalLinkClickBehavior: behavior, + terminalLinkActionPopoverEnabled: behavior === 'actions' + }), + 7 + ) + } + ) + + it('preserves a legacy-only switch update', () => { + const operations = makeOperations() + updateSettings(operations, { terminalLinkActionPopoverEnabled: true }) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe(true) + updateSettings(operations, { terminalLinkActionPopoverEnabled: false }) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe(false) + }) +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) + +describe('terminal URL click preference persistence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-terminal-link-preference-')) + writeDataFile({ version: 1, settings: { terminalLinkActionPopoverEnabled: false } }) + }) + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps an old opt-out after saving unrelated settings and reopening the profile', async () => { + const store = createStore() + store.updateSettings({ terminalFontSize: 15 }) + store.flush() + await closeTestStores() + + const reopened = createStore() + expect(reopened.getSettings()).toMatchObject({ + terminalLinkClickBehavior: 'actions', + terminalLinkActionPopoverEnabled: false, + terminalFontSize: 15 + }) + }) + + it('keeps a newly selected Actions preference after reopening the profile', async () => { + const store = createStore() + store.updateSettings({ terminalLinkClickBehavior: 'actions' }) + store.flush() + await closeTestStores() + + const reopened = createStore() + expect(reopened.getSettings()).toMatchObject({ + terminalLinkClickBehavior: 'actions', + terminalLinkActionPopoverEnabled: true + }) + }) +}) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 706f9105148e..932d4a412049 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -58,6 +58,11 @@ export function updateSettings( options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = stripRetiredGlobalSettings(updates) + const linkBehavior = updates.terminalLinkClickBehavior + if (linkBehavior === 'actions' || linkBehavior === 'open' || linkBehavior === 'none') { + // Loaded defaults are ambiguous; synchronize the legacy switch only on an explicit write. + sanitizedUpdates.terminalLinkActionPopoverEnabled = linkBehavior === 'actions' + } if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts index e51ec92b5ec0..e2078f8b9182 100644 --- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -11,6 +11,7 @@ import type { ProfileStateDomainReplacement } from './profile-state-authority' import { Store } from './store' +import { toSshExecutionHostId } from '../../../shared/execution-host' export function deferred() { let resolve!: (value: T) => void @@ -147,3 +148,20 @@ export async function fixture(legacyOpenCodeGoApiKey?: string) { } return { store, authority, readState } } + +/** The runtime writes a pane before it spawns; a runtime spawn commit binds only that pane. */ +export async function admitRuntimeSpawnPane( + store: Store, + pane: { worktreeId: string; tabId: string; leafId: string }, + connectionId?: string | null +): Promise { + await store.admitTerminalPane( + { + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }, + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index f96624d8cb1a..bc27b7f86a33 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -22,7 +22,20 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' -import { moveLeaf } from '../terminal-topology/terminal-topology-commit' +import { + admitPane, + moveLeaf, + withdrawPane, + type TerminalTopologyCommitContext +} from '../terminal-topology/terminal-topology-commit' +import type { + TerminalPaneAdmission, + TerminalPaneAdmissionOutcome +} from '../terminal-topology/terminal-pane-admission' +import type { + CommandOf, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< @@ -46,8 +59,6 @@ export type PersistPtyBindingArgs = { startupCwd?: string expectedBinding?: { ptyId: string; incarnationId?: string } expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean /** * Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer * and must be able to mint the surface it is binding. A reattach is the opposite: the pane @@ -217,16 +228,41 @@ export class PtyBindingPersistenceOperations { * the binding domain only for its runtime and partition access; the commit module owns the write. */ moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise { - const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] - return runtime.runDurableMutation( - moveLeaf(request, { - state: runtime.state, - hostIds: () => sessions.getWorkspaceSessionHostIds(), - getSession: (hostId) => sessions.getWorkspaceSession(hostId), - markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) - }) + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + moveLeaf(request, topologyCommitContext(this)) + ) + } + + /** Writes a runtime-started pane before its terminal starts; the commit module owns the write. */ + admitTerminalPane( + admission: TerminalPaneAdmission, + hostId?: string | null + ): Promise { + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + admitPane(resolveHostId(hostId), admission, topologyCommitContext(this)) ) } + + withdrawTerminalPane( + pane: CommandOf<'closePane'>, + hostId?: string | null + ): Promise { + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + withdrawPane(resolveHostId(hostId), pane, topologyCommitContext(this)) + ) + } +} + +function topologyCommitContext( + owner: PtyBindingPersistenceOperations +): TerminalTopologyCommitContext { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] + return { + state: runtime.state, + hostIds: () => sessions.getWorkspaceSessionHostIds(), + getSession: (hostId) => sessions.getWorkspaceSession(hostId), + markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) + } } function writePtyBinding( diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index ce412cf4633d..95007aaa380f 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -64,8 +64,7 @@ const SCENARIOS: Scenario[] = [ leafId: TEST_LEAF_1, ptyId: 'pty-new', incarnationId: 'inc-new', - startupCwd: '/fixture/local/sub', - hostAdmittedMembership: true + startupCwd: '/fixture/local/sub' }, placements: (leaf) => [ [NEW_TAB, 'agrees'], @@ -110,8 +109,7 @@ const SCENARIOS: Scenario[] = [ worktreeId: FOLDER_WORKTREE, tabId: 'tab-folder', leafId: TEST_LEAF_1, - ptyId: 'pty-folder', - hostAdmittedMembership: true + ptyId: 'pty-folder' }, placements: () => [ [NEW_TAB, 'agrees'], diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 3024543505a6..dd7ca9e1275f 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -18,15 +18,12 @@ export function applyPtyBinding( const reconciledIncarnation = args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false - let hostAdmittedTabCreated = false const advanceTopologyFence = (): void => { const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + // Why: a split is itself the authority — with no fence the renderer's pre-split + // pane list replays over it and the pane is lost even on the repo's first split. + const establishesMembershipAuthority = args.expectedSourceBinding !== undefined if ( !reconciledIncarnation && (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) @@ -62,7 +59,6 @@ export function applyPtyBinding( ) } else { terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. const nextTabs = [ ...(tabs ?? []), diff --git a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts index 7a1d95c91cb9..75eeef8061d2 100644 --- a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts +++ b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -71,6 +71,7 @@ describe.each(['ipc', 'runtime'])('%s failed reattach routing', (controller) => }) ctx.result = result ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding, connectionId) ctx.hostSessionBinding = { store, ...binding } shutdown = vi.spyOn(ctx.provider, 'shutdown') commit = () => commitRuntimePtySpawn(ctx) diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts index ef146779b4bf..b39a5eb01c96 100644 --- a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -1,5 +1,5 @@ import { expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' @@ -78,6 +78,7 @@ it.each( const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.stablePaneOwner = owner + await admitRuntimeSpawnPane(store, binding, connectionId) ctx.hostSessionBinding = { store, ...binding } ctx.metadataLeafId = binding.leafId commit = () => commitRuntimePtySpawn(ctx) diff --git a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts index 9fb0d65a2cb8..e8cad7828eec 100644 --- a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -1,5 +1,5 @@ import { expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -62,6 +62,7 @@ it.each(['ipc', 'runtime'])( }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } commit = () => commitRuntimePtySpawn(ctx) } diff --git a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts index 19bb64ff3693..75f154074e27 100644 --- a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -1,6 +1,6 @@ import { isTerminalSessionStorageCapacityFailure } from '../../../shared/terminal-session-state-save-failure' import { afterEach, expect, it, vi } from 'vitest' -import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { deferred, fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -72,6 +72,7 @@ it.each( const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) commit = () => commitRuntimePtySpawn(ctx) @@ -137,6 +138,7 @@ it.each(['ipc', 'runtime'])( const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } commit = () => commitRuntimePtySpawn(ctx) } diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts index 26737df9dd0d..714cf99c1155 100644 --- a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -56,6 +56,7 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = result ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } commit = () => commitRuntimePtySpawn(ctx) } diff --git a/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts new file mode 100644 index 000000000000..8acf3c96ad05 --- /dev/null +++ b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { setSecretStore } from '../../../shared/secret-store' +import { recordRemoteLayoutFrame } from '../../ipc/remote-layout-snapshot-recorder' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] + +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plaintext) => Buffer.from(plaintext), + decryptString: (ciphertext) => ciphertext.toString(), + describeProtectionGap: () => null + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const store of stores.splice(0)) { + store.freezeWrites() + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function profile() { + const directory = mkdtempSync(join(tmpdir(), 'orca-remote-layout-snapshot-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const profileId = 'remote-layout-snapshot' + new ProfileStateSqliteAuthority(databasePath, profileId).writeSerializedState( + Buffer.from(JSON.stringify(getDefaultPersistedState(directory))) + ) + const open = () => { + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(store) + return store + } + const documentRevisions = () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return new Map( + opened.db + .prepare('SELECT domain, revision FROM profile_state_documents') + .all() + .map((row) => [String(row.domain), Number(row.revision)]) + ) + } finally { + opened.db.close() + } + } + return { open, documentRevisions } +} + +const workspace = { + worktree: 'repo::/srv/repo', + publicationEpoch: 'epoch', + snapshotVersion: 3, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] +} + +it('survives a restart and saves only its own domain', () => { + const { open, documentRevisions } = profile() + const store = open() + store.flushOrThrow() + const before = documentRevisions() + const fullWrite = vi.spyOn( + ProfileStateSqliteAuthority.prototype, + 'writeCompleteSerializedDomains' + ) + + recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribeAll', { + id: 'stream', + ok: true, + result: { type: 'snapshots', snapshots: [workspace] }, + _meta: { runtimeId: 'server' } + }) + store.flushOrThrow() + + // Not a whole-profile serialization per recorded frame. + expect(fullWrite).not.toHaveBeenCalled() + const after = documentRevisions() + const changed = [...after].filter(([domain, revision]) => before.get(domain) !== revision) + expect(changed.map(([domain]) => domain)).toEqual(['remoteLayoutSnapshots']) + expect(open().getRemoteLayoutSnapshot('env')).toMatchObject({ + serverRuntimeId: 'server', + workspaces: { [workspace.worktree]: workspace } + }) +}) + +it('keeps a title-only change without arming a save, and writes it with the next save', () => { + const { open } = profile() + const store = open() + const record = (result: Record) => + recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribeAll', { + id: 'stream', + ok: true, + result, + _meta: { runtimeId: 'server' } + }) + record({ type: 'snapshots', snapshots: [workspace] }) + store.flushOrThrow() + + const timers = vi.spyOn(globalThis, 'setTimeout') + const retitled = { ...workspace, snapshotVersion: 4 } + record({ type: 'updated', ...retitled }) + expect(timers).not.toHaveBeenCalled() + timers.mockRestore() + + // Another domain's save writes only the domains marked dirty, which must include this one. + store.updateSettings({ opencodeSessionCookie: 'cookie' }) + store.flushOrThrow() + expect(open().getRemoteLayoutSnapshot('env')?.workspaces[workspace.worktree]).toEqual(retitled) +}) diff --git a/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts new file mode 100644 index 000000000000..31efc87f6665 --- /dev/null +++ b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts @@ -0,0 +1,51 @@ +import type { RemoteLayoutSnapshot } from '../../../shared/remote-layout-snapshot' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RemoteLayoutSnapshotRuntime = Pick + +const remoteLayoutSnapshotPersistenceContext = Symbol('RemoteLayoutSnapshotPersistence') +type RemoteLayoutSnapshotPersistenceContext = { + runtime: RemoteLayoutSnapshotRuntime + scheduling: WriteSchedulingOperations +} + +export class RemoteLayoutSnapshotPersistence { + readonly [remoteLayoutSnapshotPersistenceContext]: RemoteLayoutSnapshotPersistenceContext + + constructor(runtime: RemoteLayoutSnapshotRuntime, scheduling: WriteSchedulingOperations) { + this[remoteLayoutSnapshotPersistenceContext] = { runtime, scheduling } + } + + getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined { + return this[remoteLayoutSnapshotPersistenceContext].runtime.state.remoteLayoutSnapshots?.[ + environmentId + ] + } + + /** + * Only the recorder calls this (ratchet in remote-layout-snapshot-recorder.test.ts). `later` + * marks the domain dirty without arming the save timer, so it rides the next save or quit flush. + */ + setRemoteLayoutSnapshot( + environmentId: string, + snapshot: RemoteLayoutSnapshot | null, + save: 'now' | 'later' + ): void { + const { runtime, scheduling } = this[remoteLayoutSnapshotPersistenceContext] + const { [environmentId]: previous, ...others } = runtime.state.remoteLayoutSnapshots ?? {} + if (snapshot === null && previous === undefined) { + return + } + runtime.state.remoteLayoutSnapshots = snapshot + ? { ...others, [environmentId]: snapshot } + : others + if (save === 'later') { + runtime.dirtyProfileStateDomains?.add('remoteLayoutSnapshots') + return + } + // Named so the save writes only this domain and wakes no workspace-session listener. + scheduleSave(scheduling, ['remoteLayoutSnapshots']) + } +} diff --git a/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts index 6c646285d4bb..f80b7394ad1e 100644 --- a/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts +++ b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { fixture } from './profile-state-delayed-authority-fixture' -import { removeSshPtyConsumerOwnerRecovery } from '../../ssh/ssh-pty-consumer-recovery' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -43,43 +42,6 @@ describe('reserved SSH persistence', () => { expect(ownerWrites[1]?.payload).toContain('next-owner') }) - it('retries a failed consumer removal through durability even after memory is already empty', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.upsertSshPtyConsumerRecovery(recovery) - const gate = authority.pause() - const removal = expect( - removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) - ).rejects.toThrow('disk refused') - await gate.started.promise - gate.finish.reject(new Error('disk refused')) - await removal - expect(readState().sshPtyConsumerRecoveries).toHaveLength(1) - await removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) - expect(readState().sshPtyConsumerRecoveries).toEqual([]) - }) - - it('does not let an old consumer remove the newer owner ahead of it in the write queue', async () => { - const { store, authority, readState } = await fixture() - await store.upsertSshPtyConsumerRecovery(recovery) - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const older = store.flushPendingOrThrowAsync() - await gate.started.promise - const replacement = store.upsertSshPtyConsumerRecovery({ - ...recovery, - clientInstanceId: 'next-owner' - }) - const removal = removeSshPtyConsumerOwnerRecovery( - recovery.targetId, - recovery.clientInstanceId, - store - ) - gate.finish.resolve() - await Promise.all([older, replacement, removal]) - expect(readState().sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('next-owner') - }) - it('does not detach a newer replacement lease while waiting for the writer', async () => { const { store, authority, readState } = await fixture() const lease = { targetId: recovery.targetId, ptyId: 'relay-pty', state: 'attached' as const } diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index df4101229686..d5c260517b31 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -108,6 +108,7 @@ export class StateSerializationSecretHandlingOperations { } break case 'sshRemotePtyLeases': + case 'remoteLayoutSnapshots': case 'legacyPaneKeyAliasEntries': case 'migrationUnsupportedPtyEntries': stateToSave[domain] = this.runtime.state[domain] diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 2d4fb6e92d87..f6cbe9d7a832 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -16,6 +16,7 @@ import { MetadataLineageOperations } from './metadata-lineage-operations' import { ProjectCollectionOperations } from './project-collection-operations' import { AutomationPersistence } from './automation-persistence' import { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' +import { RemoteLayoutSnapshotPersistence } from './remote-layout-snapshot-persistence' import { SparsePresetPersistence } from './sparse-preset-persistence' import { PtyBindingPersistenceOperations } from './pty-binding-persistence' import { SshProfileOperations } from './ssh-profile-operations' @@ -29,6 +30,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & ProjectCollectionOperations & RepoLifecycleOperations & MobileTabSelectionPersistence & + RemoteLayoutSnapshotPersistence & SparsePresetPersistence & AutomationPersistence & MetadataLineageOperations & @@ -60,6 +62,7 @@ export type StoreDomains = { projects: ProjectCollectionOperations automations: AutomationPersistence mobileTabSelections: MobileTabSelectionPersistence + remoteLayoutSnapshots: RemoteLayoutSnapshotPersistence sparsePresets: SparsePresetPersistence ptyBindings: PtyBindingPersistenceOperations sshProfiles: SshProfileOperations @@ -75,6 +78,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ ProjectCollectionOperations, RepoLifecycleOperations, MobileTabSelectionPersistence, + RemoteLayoutSnapshotPersistence, SparsePresetPersistence, AutomationPersistence, MetadataLineageOperations, @@ -122,6 +126,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) const automations = new AutomationPersistence(runtime, flushBarriers, preferences) const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) + const remoteLayoutSnapshots = new RemoteLayoutSnapshotPersistence(runtime, scheduling) const sparsePresets = new SparsePresetPersistence(runtime, scheduling) const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) @@ -150,6 +155,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { projects, automations, mobileTabSelections, + remoteLayoutSnapshots, sparsePresets, ptyBindings, sshProfiles, diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index 1ac9fec8935b..88920f660f31 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -38,8 +38,7 @@ export function createMinimalPersistedTerminalTab(args: { color: null, sortOrder: args.existingTabCount, createdAt: Date.now(), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - pendingActivationSpawn: true + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}) } } diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index 627e4a705e72..5248ca7c0d90 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -68,7 +68,7 @@ function moveLeafInPartition( const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId] const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId])) const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {}) - const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({ + const row = createMinimalPersistedTerminalTab({ worktreeId, tabId: targetTabId, ptyId: ptyId ?? '', @@ -78,8 +78,6 @@ function moveLeafInPartition( const targetTab = { ...row, ptyId, - // A moved live pane reattaches; only an unbound one still spawns on activation. - ...(ptyId ? {} : { pendingActivationSpawn }), ...(sourceTab.shellOverride ? { shellOverride: sourceTab.shellOverride } : {}) } const nextTabs = tabs.map((tab) => diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts new file mode 100644 index 000000000000..4e3ab537f446 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -0,0 +1,66 @@ +// A runtime-started pane, written through the layout module before its terminal starts (design +// 4.1): Loader, then the command, then the Serializer, so the partition is rewritten from one model. + +import { randomUUID } from 'node:crypto' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { locatePane } from '../../../shared/workspace-layout/workspace-layout-command-steps' +import type { + CommandOf, + LayoutCommand, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' +import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load' +import type { LoadedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load-types' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model' +import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' + +/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */ +export type TerminalPaneAdmission = + | (CommandOf<'createTerminalTab'> & { tabId: string; leafId: string }) + | (CommandOf<'splitPane'> & { newLeafId: string }) + +/** 'exists': the start attaches to a pane already in the layout, so nothing is written. */ +export type TerminalPaneAdmissionOutcome = 'admitted' | 'exists' | LayoutRefusalCode + +/** The partition to write, or null when nothing changes. */ +export type PaneLayoutChange = { value: T; session: WorkspaceSessionState | null } + +export function admitPaneToSession( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + admission: TerminalPaneAdmission +): PaneLayoutChange { + const leafId = admission.type === 'createTerminalTab' ? admission.leafId : admission.newLeafId + const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) + const workspace = loaded.layout.workspaces[admission.workspace] + if (workspace && locatePane(workspace, paneKeyOf(admission.tabId, leafId))) { + return { value: 'exists', session: null } + } + return applyToSession(loaded, admission, 'admitted') +} + +export function withdrawPaneFromSession( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + pane: CommandOf<'closePane'> +): PaneLayoutChange { + return applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane, null) +} + +function applyToSession( + loaded: LoadedWorkspaceLayout, + command: LayoutCommand, + committed: T +): PaneLayoutChange { + const applied = applyLayoutCommand(loaded.layout, command, { + mintId: randomUUID, + mintLeafId: randomUUID, + now: Date.now + }) + return applied.ok + ? { value: committed, session: saveWorkspaceLayout({ ...loaded, layout: applied.model }) } + : { value: applied.code, session: null } +} diff --git a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts index b11a2a244699..9e10e984b391 100644 --- a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts +++ b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts @@ -100,14 +100,24 @@ describe('an old client pane tree that predates a split or close', () => { it('keeps a pane another client split since the tree was built', async () => { const store = await openWithPanes([TEST_LEAF_1]) - // `terminal.split` from the CLI: a host-created pane bound against its source pane. + // `terminal.split` from the CLI: the runtime writes the pane, then binds it against its source. + await expect( + store.admitTerminalPane({ + type: 'splitPane', + workspace: WT, + tabId: 'tab-a', + leafId: TEST_LEAF_1, + direction: 'vertical', + newLeafId: NEW_LEAF + }) + ).resolves.toBe('admitted') await expect( store.persistPtyBinding({ worktreeId: WT, tabId: 'tab-a', leafId: NEW_LEAF, ptyId: 'pty-new', - hostAdmittedMembership: true, + mayCreate: false, expectedSourceBinding: { tabId: 'tab-a', leafId: TEST_LEAF_1, ptyId: 'pty-1' } }) ).resolves.toBe(true) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dc1b562a51da..756869d08d79 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -5,6 +5,17 @@ import type { TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { + CommandOf, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import { + admitPaneToSession, + withdrawPaneFromSession, + type PaneLayoutChange, + type TerminalPaneAdmission, + type TerminalPaneAdmissionOutcome +} from './terminal-pane-admission' import { startSpan } from '../../observability/tracer' import { terminalSurfaceCloseMutation, @@ -18,7 +29,12 @@ import { assignWorkspaceSessionPartition } from './terminal-topology-membership' // the pane move; the close transform still lives in runtime/ and other writers move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' +type TerminalTopologyCommitKind = + | 'close_leaf' + | 'close_tab' + | 'move_leaf' + | 'admit_pane' + | 'withdraw_pane' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -43,6 +59,45 @@ export function moveLeaf( ) } +/** + * Writes the pane, its tab-bar entry and group before its terminal starts (design 4.1), through the + * layout module. + */ +export function admitPane( + hostId: ExecutionHostId, + admission: TerminalPaneAdmission, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'admit_pane', + () => + commitPaneChange( + hostId, + admitPaneToSession(hostId, context.getSession(hostId), admission), + context + ), + (outcome) => (outcome === 'admitted' || outcome === 'exists' ? undefined : outcome) + ) +} + +/** Removes a pane `admitPane` wrote whose start then failed, so a failed create leaves no tab. */ +export function withdrawPane( + hostId: ExecutionHostId, + pane: CommandOf<'closePane'>, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'withdraw_pane', + () => + commitPaneChange( + hostId, + withdrawPaneFromSession(hostId, context.getSession(hostId), pane), + context + ), + (code) => code ?? undefined + ) +} + /** * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. * Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code. @@ -82,7 +137,7 @@ type TopologyState = Pick< 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' > -type TerminalTopologyCommitContext = { +export type TerminalTopologyCommitContext = { state: TopologyState hostIds: () => ExecutionHostId[] getSession: (hostId: ExecutionHostId) => WorkspaceSessionState @@ -147,3 +202,19 @@ function commitLeafMove( rollback: () => restores.forEach((restore) => restore()) } } + +function commitPaneChange( + hostId: ExecutionHostId, + change: PaneLayoutChange, + context: TerminalTopologyCommitContext +): DurableProfileStateMutation { + if (!change.session) { + return { value: change.value, persist: false } + } + const restore = writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + change.session + ) + return { value: change.value, rollback: restore } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts index 4b44bf6fdb44..f68b7595dbb8 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -553,24 +553,33 @@ async function runSeed(seed: number): Promise { const ptyId = ids.pty(worktreeId) const ticks = Math.floor(random() * 4) log.push(`${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId} stale-save+${ticks}`) - // `terminal.split` from a client or the CLI: main mints the pane while the window's - // in-flight save lacks it. + // `terminal.split` from a client or the CLI: main writes the pane, then binds it, while + // the window's in-flight save lacks it. + writer = 'host split' const [bound] = await Promise.all([ - store.persistPtyBinding(() => { - writer = 'host split' - return { - worktreeId, + store + .admitTerminalPane({ + type: 'splitPane', + workspace: worktreeId, tabId: target.tabId, - leafId, - ptyId, - hostAdmittedMembership: true, - expectedSourceBinding: { + leafId: target.leafId, + direction: 'vertical', + newLeafId: leafId + }) + .then(() => + store.persistPtyBinding({ + worktreeId, tabId: target.tabId, - leafId: target.leafId, - ptyId: target.pane.ptyId! - } - } - }), + leafId, + ptyId, + mayCreate: false, + expectedSourceBinding: { + tabId: target.tabId, + leafId: target.leafId, + ptyId: target.pane.ptyId! + } + }) + ), staleSave(window.snapshot(), ticks) ]) expect(bound).toBe(true) diff --git a/src/main/plugins/plugin-host-conformance.test.ts b/src/main/plugins/plugin-host-conformance.test.ts deleted file mode 100644 index 43ec5c2f227d..000000000000 --- a/src/main/plugins/plugin-host-conformance.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { PLUGIN_HOST_API_V0 } from '../../shared/plugins/plugin-host-api' -import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' -import { - admitPluginPanelCall, - createPluginPanelCallAdmission -} from '../../shared/plugins/plugin-panel-call-admission' -import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' -import type { MethodHandler } from '../../wsl-guest/dispatcher' -import { - RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, - RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, - registerRelayPluginHostCallHandlers -} from '../../relay/plugin-host-call-handler' -import { - executePluginHostCallRequest, - type PluginHostCallPolicy, - type ResolvePluginHostCallPolicy -} from './plugin-host-call-adapter' -import type { PluginHostServices } from './plugin-host-methods' - -const PLUGIN_KEY = 'orca-samples.demo' -const WORKTREE_ID = 'repo-id::/Users/private/orca' -const TERMINAL_ID = 'terminal:local:one' - -type HostCallAdapter = (request: unknown, viaPanel: boolean) => Promise - -function createServices(): PluginHostServices { - return { - resolveActiveWorktreeContext: vi.fn().mockResolvedValue({ - worktreeId: WORKTREE_ID, - branch: 'main', - displayName: 'Orca', - path: '/Users/private/orca' - }), - listWorktreeTerminals: vi - .fn() - .mockResolvedValue([{ id: TERMINAL_ID, title: '/home/private/orca' }]), - sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }), - dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }), - storage: { - get: vi.fn().mockReturnValue('stored'), - set: vi.fn().mockReturnValue({ ok: true }), - delete: vi.fn(), - keys: vi.fn().mockReturnValue(['alpha']) - }, - secrets: { - get: vi.fn().mockReturnValue({ ok: true, value: 'secret' }), - set: vi.fn().mockReturnValue({ ok: true }), - delete: vi.fn() - }, - settings: { - getAll: vi.fn().mockReturnValue({ theme: 'dark' }), - set: vi.fn().mockReturnValue({ ok: true }) - }, - subscribeEvents: vi.fn().mockImplementation((_pluginKey, events) => events) - } -} - -function createPolicy( - grantedCapabilities: readonly PluginCapabilityKind[] | null, - services: PluginHostServices = createServices(), - audit = { record: vi.fn().mockResolvedValue(undefined) } -): PluginHostCallPolicy { - return { grantedCapabilities, services, audit } -} - -function createAdapters( - resolvePolicy: ResolvePluginHostCallPolicy, - limits?: { maxBytes?: number; maxMessages?: number; perMs?: number } -): Record { - const relayHandlers = new Map() - registerRelayPluginHostCallHandlers( - { onRequest: (method, handler) => relayHandlers.set(method, handler) }, - (context) => (context.clientId === 1 ? PLUGIN_KEY : null), - resolvePolicy, - { panelAdmission: createPluginPanelCallAdmission({ limits, now: () => 0 }) } - ) - const desktopAdmission = createPluginPanelCallAdmission({ limits, now: () => 0 }) - return { - 'desktop-main': async (request, viaPanel) => { - if (viaPanel) { - const admissionRefusal = admitPluginPanelCall(desktopAdmission, PLUGIN_KEY, request) - if (admissionRefusal) { - return admissionRefusal - } - } - return executePluginHostCallRequest({ - pluginKey: PLUGIN_KEY, - request, - viaPanel, - resolvePolicy - }) - }, - relay: async (request, viaPanel) => { - const registeredMethod = viaPanel - ? RELAY_PLUGIN_PANEL_HOST_CALL_METHOD - : RELAY_PLUGIN_WORKER_HOST_CALL_METHOD - return (await relayHandlers.get(registeredMethod)!(request as Record, { - clientId: 1, - isStale: () => false - })) as PluginPanelActionOutcome - } - } -} - -const successParams: Record = { - 'workspace.readContext': {}, - 'terminal.sendText': { terminalId: TERMINAL_ID, text: 'echo hi', enter: true }, - 'notifications.show': { title: 'Hello' }, - 'storage.get': { key: 'alpha' }, - 'storage.set': { key: 'alpha', value: 1 }, - 'storage.delete': { key: 'alpha' }, - 'storage.keys': {}, - 'secrets.get': { key: 'token' }, - 'secrets.set': { key: 'token', value: 'secret' }, - 'secrets.delete': { key: 'token' }, - 'settings.get': {}, - 'settings.set': { key: 'theme', value: 'dark' }, - 'events.subscribe': { events: ['worktree.created'] } -} - -describe('plugin host main/relay conformance', () => { - it('runs a granted success through both transports for all 13 v0 methods', async () => { - expect(PLUGIN_HOST_API_V0).toHaveLength(13) - expect(Object.keys(successParams).sort()).toEqual( - PLUGIN_HOST_API_V0.map((entry) => entry.name).sort() - ) - expect(PLUGIN_HOST_API_V0.every((entry) => entry.stability === 'experimental')).toBe(true) - expect(PLUGIN_HOST_API_V0.every((entry) => entry.scope.length > 0)).toBe(true) - - for (const spec of PLUGIN_HOST_API_V0) { - const policy = createPolicy([spec.capability]) - const resolvePolicy = vi.fn().mockResolvedValue(policy) - const outcomes = await Promise.all( - Object.values(createAdapters(resolvePolicy)).map((adapter) => - adapter({ method: spec.name, params: successParams[spec.name] }, spec.panel) - ) - ) - expect(outcomes, spec.name).toHaveLength(2) - expect(outcomes[0], spec.name).toEqual(outcomes[1]) - expect(outcomes[0], spec.name).toMatchObject({ ok: true }) - } - }) - - it('projects workspace context without host paths on main and relay', async () => { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['workspace:read'])) - for (const adapter of Object.values(createAdapters(resolvePolicy))) { - const outcome = await adapter({ method: 'workspace.readContext', params: {} }, true) - expect(outcome).toEqual({ - ok: true, - value: { - branch: 'main', - displayName: 'Orca', - terminals: [{ id: TERMINAL_ID }] - } - }) - expect(outcome).not.toHaveProperty('value.path') - expect(outcome).not.toHaveProperty('value.worktreeId') - } - }) - - const deniedCases: { - name: string - request: unknown - viaPanel: boolean - policy: () => PluginHostCallPolicy - code: string - }[] = [ - { - name: 'missing or stale consent', - request: { method: 'workspace.readContext', params: {} }, - viaPanel: true, - policy: () => createPolicy(null), - code: 'consent_required' - }, - { - name: 'missing capability', - request: { method: 'workspace.readContext', params: {} }, - viaPanel: true, - policy: () => createPolicy([]), - code: 'capability_denied' - }, - { - name: 'unknown method', - request: { method: 'workspace.erase', params: {} }, - viaPanel: false, - policy: () => createPolicy(['workspace:read']), - code: 'unknown_method' - }, - { - name: 'malformed params', - request: { - method: 'terminal.sendText', - params: { terminalId: TERMINAL_ID, text: '' } - }, - viaPanel: true, - policy: () => createPolicy(['terminal:send']), - code: 'invalid_params' - }, - { - name: 'panel-forbidden method', - request: { method: 'storage.get', params: { key: 'alpha' } }, - viaPanel: true, - policy: () => createPolicy(['storage']), - code: 'panel_forbidden' - }, - { - name: 'malformed result', - request: { - method: 'notifications.show', - params: { title: 'Hello' } - }, - viaPanel: true, - policy: () => { - const services = createServices() - services.dispatchPluginNotification = vi - .fn() - .mockResolvedValue({ delivered: 'yes' } as unknown as { delivered: boolean }) - return createPolicy(['notifications:show'], services) - }, - code: 'action_failed' - }, - { - name: 'mutation audit failure', - request: { - method: 'storage.set', - params: { key: 'alpha', value: 1 } - }, - viaPanel: false, - policy: () => - createPolicy(['storage'], createServices(), { - record: vi.fn().mockRejectedValue(new Error('disk full')) - }), - code: 'action_failed' - } - ] - - it.each(deniedCases)('returns identical $code codes for $name', async (testCase) => { - const outcomes: PluginPanelActionOutcome[] = [] - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockImplementation(() => testCase.policy()) - const adapter = createAdapters(resolvePolicy)[adapterName]! - outcomes.push(await adapter(testCase.request, testCase.viaPanel)) - } - expect(outcomes[0]).toMatchObject({ ok: false, code: testCase.code }) - expect(outcomes[1]).toMatchObject({ ok: false, code: testCase.code }) - expect(outcomes[0]).toEqual(outcomes[1]) - }) - - it('enforces the same per-plugin panel budget on desktop main and relay', async () => { - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) - const adapter = createAdapters(resolvePolicy, { - maxMessages: 1, - perMs: 10_000 - })[adapterName]! - - await expect( - adapter({ method: 'notifications.show', params: { title: 'first' } }, true) - ).resolves.toMatchObject({ ok: true }) - await expect( - adapter({ method: 'notifications.show', params: { title: 'second' } }, true) - ).resolves.toEqual({ - ok: false, - code: 'rate_limited', - error: 'too many panel requests' - }) - } - }) - - it('charges malformed and oversized panel traffic before schema parsing', async () => { - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) - const adapter = createAdapters(resolvePolicy, { - maxBytes: 128, - maxMessages: 2, - perMs: 10_000 - })[adapterName]! - - await expect( - adapter({ method: 'notifications.show', unexpected: true }, true) - ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) - await expect( - adapter( - { - method: 'notifications.show', - params: { title: 'x'.repeat(256) } - }, - true - ) - ).resolves.toEqual({ - ok: false, - code: 'invalid_request', - error: 'panel message exceeds the size limit' - }) - await expect( - adapter({ method: 'notifications.show', params: { title: 'third' } }, true) - ).resolves.toEqual({ - ok: false, - code: 'rate_limited', - error: 'too many panel requests' - }) - expect(resolvePolicy).not.toHaveBeenCalled() - } - }) - - it('binds relay plugin identity to the requesting connection', async () => { - const relayHandlers = new Map() - const services = createServices() - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'], services)) - const resolveIdentity = vi - .fn() - .mockImplementation(({ clientId }: { clientId: number }) => - clientId === 7 ? PLUGIN_KEY : null - ) - registerRelayPluginHostCallHandlers( - { onRequest: (method, handler) => relayHandlers.set(method, handler) }, - resolveIdentity, - resolvePolicy - ) - const handler = relayHandlers.get(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD)! - - await expect( - handler( - { method: 'storage.get', params: { key: 'alpha' } }, - { clientId: 7, isStale: () => false } - ) - ).resolves.toMatchObject({ ok: true }) - expect(services.storage.get).toHaveBeenCalledWith(PLUGIN_KEY, 'alpha') - - await expect( - handler( - { method: 'storage.get', params: { key: 'alpha' } }, - { clientId: 8, isStale: () => false } - ) - ).resolves.toMatchObject({ ok: false, code: 'unavailable' }) - expect(resolvePolicy).toHaveBeenCalledTimes(1) - }) - - it('rejects malformed envelopes and client-supplied authority before policy resolution', async () => { - const requests = [ - { pluginKey: '../evil', method: 'storage.get', params: { key: 'alpha' } }, - { - pluginKey: PLUGIN_KEY, - method: 'storage.get', - params: { key: 'alpha' }, - grantedCapabilities: ['storage'] - }, - { - pluginKey: PLUGIN_KEY, - method: 'storage.get', - params: { key: 'alpha' }, - viaPanel: false - } - ] - for (const request of requests) { - for (const adapterName of ['desktop-main', 'relay']) { - const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'])) - const outcome = await createAdapters(resolvePolicy)[adapterName]!(request, false) - expect(outcome).toMatchObject({ ok: false, code: 'invalid_request' }) - expect(resolvePolicy).not.toHaveBeenCalled() - } - } - }) -}) diff --git a/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts b/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts index 178ff34660d9..6c0269d65d42 100644 --- a/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts +++ b/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts @@ -4,10 +4,18 @@ // Drives the REAL local call site (`resolveAgentForegroundProcess`) under the // documented agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms in // agent-completion-coordinator.ts) across several concurrently-inspecting agent -// panes, and counts how many `ps -axo pid=,ppid=,stat=,command=` scans actually +// panes, and counts how many full process-table scans actually // spawn. Pre-fix the call site forked one `ps` per pane per tick; with the // shared snapshot cache the scans collapse to ~one per tick regardless of pane // count, while each pane still resolves the same foreground identity. +import type * as FsPromises from 'node:fs/promises' +import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names' +import type * as FullReader from '../../shared/process-table-snapshot-reader' +import { + createProcessTablePsFixture, + type PsFixtureRow +} from '../../shared/process-table-ps-test-fixture' +import type * as Foreground from './agent-foreground-process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { execFileMock, psScanCount } = vi.hoisted(() => ({ @@ -17,8 +25,22 @@ const { execFileMock, psScanCount } = vi.hoisted(() => ({ vi.mock('child_process', () => ({ execFile: execFileMock })) -import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' -import { resolveAgentForegroundProcess } from './agent-foreground-process' +// Inject /dev data without bypassing the production translator. +vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => { + const original = await importOriginal() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: async (path: string) => readProcStatMock(path) +})) + +let fullReader: typeof FullReader +let foreground: typeof Foreground const ACTIVE_POLL_INTERVAL_MS = 750 // mirrors agent-completion-coordinator.ts const PANE_COUNT = 6 // reporter saw it with "only three projects" -> several agent panes @@ -30,33 +52,63 @@ const shellPid = (pane: number): number => 100 + pane * 1000 // A real `ps` returns the whole system, so one shared snapshot must contain // every pane's shell + foreground codex child. Each pane resolves its own // agent from the single scan. -const PS_OUTPUT = Array.from({ length: PANE_COUNT }, (_, pane) => { - const shell = shellPid(pane) - return [ - `${shell} 99 Ss bash -i`, - `${shell + 1} ${shell} S+ node /Users/dev/.nvm/versions/node/bin/codex` - ].join('\n') -}).join('\n') +function processRows(): PsFixtureRow[] { + return Array.from({ length: PANE_COUNT }, (_, pane) => { + const shell = shellPid(pane) + const agent = shell + 1 + const base = { terminalMinor: pane, startTime: 'Thu Sep 3 16:02:01 2026', startTicks: 1_000 } + return [ + { ...base, pid: shell, ppid: 99, pgid: shell, tpgid: agent, stat: 'Ss', command: 'bash -i' }, + { + ...base, + pid: agent, + ppid: shell, + pgid: agent, + tpgid: agent, + stat: 'S+', + command: 'node /Users/dev/.nvm/versions/node/bin/codex' + } + ] + }).flat() +} +const psFixture = createProcessTablePsFixture(processRows) +const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path)) function installCountingPsMock(): void { - execFileMock.mockImplementation((cmd: string, args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - if (cmd === 'ps' && Array.isArray(args) && args.includes('-axo')) { + execFileMock.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + callback: (err: unknown, result: { stdout: string; stderr: string }) => void + ) => { + expect(cmd).toBe('ps') + expect(args[0]).toBe('-axo') + expect(args[1]).toBe( + process.platform === 'darwin' + ? 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command=' + : 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command=' + ) psScanCount.value += 1 + callback(null, { stdout: psFixture.render(args[1]), stderr: '' }) } - callback(null, { stdout: PS_OUTPUT, stderr: '' }) - }) + ) } -describe('#6288 agent foreground inspection ps-scan volume', () => { +describe.each(['darwin', 'linux'])('#6288 foreground ps scan volume (%s)', (hostPlatform) => { let platform: PropertyDescriptor | undefined - beforeEach(() => { + beforeEach(async () => { execFileMock.mockReset() - resetProcessTableSnapshotForTests() + readProcStatMock.mockClear() psScanCount.value = 0 platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + // Column constants are chosen at import time, so each host needs a fresh module graph. + vi.resetModules() + fullReader = await import('../../shared/process-table-snapshot-reader') + fullReader.resetProcessTableSnapshotForTests() + foreground = await import('./agent-foreground-process') vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(0) }) @@ -66,6 +118,15 @@ describe('#6288 agent foreground inspection ps-scan volume', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } + if (hostPlatform === 'darwin') { + expect(readProcStatMock).not.toHaveBeenCalled() + } else { + expect(readProcStatMock).toHaveBeenCalled() + } + // Production catches read errors; assert each path resolved against the fixture at read time. + for (const [index, result] of readProcStatMock.mock.results.entries()) { + expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return') + } }) it('bounds ps scans by poll ticks, not by pane count, while resolving every pane', async () => { @@ -76,7 +137,7 @@ describe('#6288 agent foreground inspection ps-scan volume', () => { // All panes inspect concurrently within the tick (worst case for a busy relay). const resolved = await Promise.all( Array.from({ length: PANE_COUNT }, (_, pane) => - resolveAgentForegroundProcess(shellPid(pane), 'node') + foreground.resolveAgentForegroundProcess(shellPid(pane), 'node') ) ) // Caching must not change the answer: every pane still resolves the agent. @@ -87,7 +148,7 @@ describe('#6288 agent foreground inspection ps-scan volume', () => { // Pre-fix this equals totalInspections (one scan per inspection). With the // shared cache, concurrent panes within a tick share one scan and the 500ms // TTL forces a fresh scan each new 750ms tick -> ~one scan per tick. - expect(psScanCount.value).toBeLessThanOrEqual(TICKS + 1) + expect(psScanCount.value).toBe(TICKS) expect(psScanCount.value).toBeLessThan(totalInspections / 2) }) }) diff --git a/src/main/providers/execution-host-provider-dispatch.ts b/src/main/providers/execution-host-provider-dispatch.ts index da7ba4b54d8a..7895b2e5e8ff 100644 --- a/src/main/providers/execution-host-provider-dispatch.ts +++ b/src/main/providers/execution-host-provider-dispatch.ts @@ -1,33 +1,17 @@ /** - * Host-keyed provider dispatch: one entry per execution host kind, with `local` among them. + * Host-keyed provider dispatch: one route per execution host kind, `local` included. * - * The incumbent spelling across main is `const c = repo.connectionId; c ? sshProvider(c) : local()`, - * where `null` means *both* "resolved: this is local" and "could not resolve". Every path that - * cannot determine the host therefore answers "local" and runs remote work on the client — the - * #11163 defect class, which has produced a reproduced cross-host leak (an `ssh:` worktree - * resolving to another target) and near-misses where a transcript that exists only on a remote host - * would have been read locally. The shape also cannot express a `runtime:` host at all. + * The input is an `ExecutionHostId`, never null, and an id that names no host throws instead of + * degrading to `local`, so work whose host is unknown never runs on this client (#11163). The + * resolution layer (`getRepoExecutionHostId`, `getWorktreeExecutionHostId`, + * `resolveWorktreeExecutionHost`) reports `unresolved` as its own verdict. * - * This module removes that spelling. Its input is an `ExecutionHostId`, which is never null, and an - * id that names no host throws instead of degrading. `getRepoExecutionHostId` / - * `getWorktreeExecutionHostId` / `resolveWorktreeExecutionHost` are the resolution layer that feeds - * it; the last one already answers `unresolved` as a distinct verdict rather than "local". - * - * Why a route union rather than a uniform `getGitProviderForHost(): IGitProvider`: - * - * - `local` git and filesystem work takes per-worktree execution options (`wslDistro`, - * `sharedLinkPaths`, admission tier). A registered stateless provider would silently drop WSL - * routing, so the local git and filesystem routes carry a factory that is built per call. - * - `runtime:` is never executed in this process; it is forwarded to that server, which - * treats it as its own `local`. A runtime repo's `connectionId` names the *server's* SSH target, - * so handing it to this client's SSH table would dial a same-named target on the wrong host. - * - * So each host kind is its own variant and callers switch exhaustively; `runtime` cannot collapse - * into `local` by omission. - * - * Inside the `ssh` variant, `provider: null` means "remote and currently unreachable", never - * "local" — loss of contact is never evidence of locality (the `live` / `unverifiable` / `exited` - * rule in AGENTS.md). + * Each kind is its own variant so callers switch exhaustively: + * - `local` git and filesystem routes carry a per-call factory, because they need per-worktree + * options (WSL distro, shared links, admission tier) a shared provider would drop. + * - `runtime:` is forwarded to that server. Its repo's `connectionId` names the server's SSH + * target, so it must never reach this client's SSH table. + * - `ssh` with `provider: null` means "remote and unreachable", never "local". */ import { diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts index ed3f5e5619a4..1f9ad453fd70 100644 --- a/src/main/providers/process-cwd.ts +++ b/src/main/providers/process-cwd.ts @@ -4,11 +4,6 @@ import { readlink } from 'node:fs/promises' /** * Resolve the current working directory of a local process by pid. * - * Why duplicated from `src/relay/pty-shell-utils.ts`: the relay and Electron - * main process have separate build graphs, and cross-importing across them - * is not a pattern used in this repo. The function is short and pure, and - * the duplication is cheaper than reshaping both bundle graphs. - * * Tries `/proc//cwd` on Linux, falls back to `lsof -d cwd` on macOS. * Returns `''` when neither works (including Windows, where `/proc` is * absent and `lsof` is not native). diff --git a/src/main/providers/ssh-directory-legacy-compatibility.test.ts b/src/main/providers/ssh-directory-legacy-compatibility.test.ts deleted file mode 100644 index f6234efaee0d..000000000000 --- a/src/main/providers/ssh-directory-legacy-compatibility.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { readSshDirectoryBounded } from './ssh-directory-listing' - -function fixture() { - const listeners = new Map) => void>() - const mux = { - request: vi.fn(), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - callback: (params: Record) => void - ) => { - listeners.set(method, callback) - return () => listeners.delete(method) - } - } - mux.request.mockRejectedValueOnce( - Object.assign(new Error('old host'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements all mux methods used by the response reader. - return { mux: mux as unknown as SshChannelMultiplexer, mock: mux, listeners } -} - -const entries = [{ name: 'README.md', isDirectory: false, isSymlink: false }] - -it('preserves small old plain replies on system SSH without SFTP', async () => { - const f = fixture() - f.mock.request.mockResolvedValueOnce(entries) - await expect(readSshDirectoryBounded(f.mux, '/remote')).resolves.toEqual(entries) - expect(f.mock.request).toHaveBeenLastCalledWith('fs.readDir', { - dirPath: '/remote', - __streamResponse: true - }) -}) - -it('preserves old streamed replies without SFTP', async () => { - const f = fixture() - const encoded = Buffer.from(JSON.stringify(entries)) - f.mock.request.mockImplementationOnce(async () => { - f.listeners.get('git.responseChunk')?.({ - streamId: 7, - seq: 0, - data: encoded.toString('base64') - }) - f.listeners.get('git.responseEnd')?.({ streamId: 7 }) - return { __orcaGitResponseStream: { streamId: 7, totalBytes: encoded.length, chunkCount: 1 } } - }) - await expect(readSshDirectoryBounded(f.mux, '/remote')).resolves.toEqual(entries) -}) - -it('prefers bounded SFTP to legacy producer allocation', async () => { - const f = fixture() - const fallback = vi.fn().mockResolvedValue(entries) - await expect(readSshDirectoryBounded(f.mux, '/remote', fallback)).resolves.toEqual(entries) - expect(f.mock.request).toHaveBeenCalledTimes(1) - expect(fallback).toHaveBeenCalledOnce() -}) - -it('does not fall back for arbitrary failures', async () => { - const f = fixture() - f.mock.request.mockReset().mockRejectedValue(new Error('permission denied')) - const fallback = vi.fn() - await expect(readSshDirectoryBounded(f.mux, '/remote', fallback)).rejects.toThrow( - 'permission denied' - ) - expect(f.mock.request).toHaveBeenCalledTimes(1) - expect(fallback).not.toHaveBeenCalled() -}) - -it('rejects complete old replies that exceed metadata capacity', async () => { - const f = fixture() - f.mock.request.mockResolvedValueOnce(Array.from({ length: 100001 }, () => entries[0])) - await expect(readSshDirectoryBounded(f.mux, '/remote')).rejects.toThrow('too large') -}) - -it('rejects oversized streamed old replies before accepting chunks', async () => { - const f = fixture() - f.mock.request.mockResolvedValueOnce({ - __orcaGitResponseStream: { streamId: 7, totalBytes: 17 * 1024 * 1024, chunkCount: 1 } - }) - await expect(readSshDirectoryBounded(f.mux, '/remote')).rejects.toThrow('retention budget') - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 7 }) -}) - -it('propagates legacy errors without another fallback', async () => { - const f = fixture() - f.mock.request.mockRejectedValueOnce(new Error('legacy permission denied')) - await expect(readSshDirectoryBounded(f.mux, '/remote')).rejects.toThrow( - 'legacy permission denied' - ) - expect(f.mock.request).toHaveBeenCalledTimes(2) -}) diff --git a/src/main/providers/ssh-directory-listing-options.test.ts b/src/main/providers/ssh-directory-listing-options.test.ts deleted file mode 100644 index 546fecbe116a..000000000000 --- a/src/main/providers/ssh-directory-listing-options.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { readSshDirectoryBounded, readSshDirectoryWithSftpFallback } from './ssh-directory-listing' -import { withSftpDirectoryHandles } from './sftp-directory-test-fixture' -import { readSftpDirectory } from './ssh-sftp-directory-listing' -import { readRelayDirectoryBounded } from '../../relay/fs-directory-listing' -import { listSshFiles } from './ssh-file-listing' - -function muxFixture() { - const mock = { - request: vi.fn().mockResolvedValue([]), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: () => () => {} - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are the stream reader's only multiplexer operations. - return { mock, mux: mock as unknown as SshChannelMultiplexer } -} -function sftpFixture() { - const mock = { - readdir: vi.fn((_path, cb) => - cb(null, [ - { filename: 'linked', attrs: { isSymbolicLink: () => true, isDirectory: () => false } } - ]) - ), - stat: vi.fn((_path, cb) => cb(null, { isDirectory: () => true })), - end: vi.fn() - } - return { mock, sftp: withSftpDirectoryHandles(mock) } -} - -describe('integrated bounded inventory options', () => { - it('forwards both listing preferences without serializing cancellation signals', async () => { - const { mux, mock } = muxFixture() - const signal = new AbortController().signal - await listSshFiles(mux, '/root', { includeIgnored: false, followSymlinks: true, signal }) - expect(mock.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/root', includeIgnored: false, followSymlinks: true, __streamResponse: true }, - { signal, timeoutMs: undefined } - ) - await readSshDirectoryBounded(mux, '/root', undefined, { followSymlinks: false }) - expect(mock.request).toHaveBeenLastCalledWith('fs.readDirBounded', { - dirPath: '/root', - followSymlinks: false, - __streamResponse: true - }) - }) - it('preserves the symlink preference on a legacy relay without SFTP', async () => { - const { mux, mock } = muxFixture() - mock.request.mockRejectedValueOnce( - Object.assign(new Error('Method not found'), { code: -32601 }) - ) - await readSshDirectoryBounded(mux, '/root', undefined, { followSymlinks: false }) - expect(mock.request).toHaveBeenLastCalledWith('fs.readDir', { - dirPath: '/root', - followSymlinks: false, - __streamResponse: true - }) - }) - it('does not probe link targets on either SFTP route when disabled', async () => { - const { sftp, mock } = sftpFixture() - expect(await readSftpDirectory(sftp, '/root', { followSymlinks: false })).toEqual([ - { name: 'linked', isDirectory: false, isSymlink: true } - ]) - expect(mock.stat).not.toHaveBeenCalled() - const { mux, mock: transport } = muxFixture() - transport.request.mockRejectedValue( - Object.assign(new Error('Method not found'), { code: -32601 }) - ) - const fallback = sftpFixture() - expect( - await readSshDirectoryWithSftpFallback(mux, '/root', async () => fallback.sftp, { - followSymlinks: true - }) - ).toEqual([{ name: 'linked', isDirectory: true, isSymlink: true }]) - expect(fallback.mock.stat).toHaveBeenCalledTimes(1) - expect(fallback.mock.end).toHaveBeenCalledTimes(1) - }) - it('preserves complete relay directory classification with opt-in links', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-integrated-dir-')) - try { - await mkdir(join(root, 'target')) - await symlink(join(root, 'target'), join(root, 'linked'), 'dir') - expect( - (await readRelayDirectoryBounded(root, undefined, { followSymlinks: false })).find( - (entry) => entry.name === 'linked' - )?.isDirectory - ).toBe(false) - expect( - (await readRelayDirectoryBounded(root, undefined, { followSymlinks: true })).find( - (entry) => entry.name === 'linked' - )?.isDirectory - ).toBe(true) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/providers/ssh-directory-listing.ts b/src/main/providers/ssh-directory-listing.ts deleted file mode 100644 index 0b5fdf44412e..000000000000 --- a/src/main/providers/ssh-directory-listing.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { DirEntry } from '../../shared/filesystem-entry-types' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { validateDirectoryListing } from '../../shared/directory-listing-budget' -import { readSftpDirectory } from './ssh-sftp-filesystem-provider' -import type { SftpFactory } from './ssh-filesystem-download' - -export function readSshDirectoryWithSftpFallback( - mux: SshChannelMultiplexer, - dirPath: string, - createSftp?: SftpFactory, - options?: { followSymlinks?: boolean } -): Promise { - return readSshDirectoryBounded( - mux, - dirPath, - createSftp - ? async () => { - const sftp = await createSftp() - try { - return await readSftpDirectory(sftp, dirPath, options) - } finally { - sftp.end() - } - } - : undefined, - options - ) -} - -export async function readSshDirectoryBounded( - mux: SshChannelMultiplexer, - dirPath: string, - fallback?: () => Promise, - options?: { followSymlinks?: boolean } -) { - try { - return validateDirectoryListing( - await requestGitStreamable( - mux, - 'fs.readDirBounded', - { dirPath, ...options }, - { maxResponseBytes: 16 * 1024 * 1024 } - ) - ) - } catch (error) { - if (isMethodNotFoundError(error)) { - if (fallback) { - return fallback() - } - // Old hosts allocate before replying; bound transport retention and validate the complete result. - return validateDirectoryListing( - await requestGitStreamable( - mux, - 'fs.readDir', - { dirPath, ...options }, - { - maxResponseBytes: 16 * 1024 * 1024 - } - ) - ) - } - throw error - } -} diff --git a/src/main/providers/ssh-directory-streaming-real.test.ts b/src/main/providers/ssh-directory-streaming-real.test.ts deleted file mode 100644 index eeb649c733da..000000000000 --- a/src/main/providers/ssh-directory-streaming-real.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { generateKeyPairSync } from 'node:crypto' -import { Client, Server } from 'ssh2' -import type { SFTPWrapper } from 'ssh2' -import { describe, expect, it } from 'vitest' -import { readDirectoryEntriesViaSftp } from './ssh-filesystem-provider-sftp' -import { readSftpDirectory } from './ssh-sftp-directory-listing' - -async function createServer() { - const { privateKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, - publicKeyEncoding: { type: 'pkcs1', format: 'pem' } - }) - let reads = 0 - let closes = 0 - const positions = new Map() - const server = new Server({ hostKeys: [privateKey] }, (connection) => { - connection - .on('authentication', (context) => context.accept()) - .on('ready', () => { - connection.on('session', (accept) => { - accept().on('sftp', (acceptSftp) => { - const stream = acceptSftp() - stream.on('OPENDIR', (id, path) => { - positions.set(path, 0) - stream.handle(id, Buffer.from(path)) - }) - stream.on('READDIR', (id, handle) => { - reads++ - const key = handle.toString() - const start = positions.get(key) ?? 0 - if (start >= 1000) { - stream.status(id, 1) - return - } - positions.set(key, start + 100) - stream.name( - id, - Array.from({ length: 100 }, (_, offset) => ({ - filename: `file-${start + offset}.txt`, - longname: '', - attrs: { mode: 0o100644, size: 0, uid: 0, gid: 0, atime: 0, mtime: 0 } - })) - ) - }) - stream.on('CLOSE', (id, handle) => { - closes++ - positions.delete(handle.toString()) - stream.status(id, 0) - }) - }) - }) - }) - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('No fixture port') - } - const client = new Client() - await new Promise((resolve, reject) => { - client.once('ready', resolve).once('error', reject) - client.connect({ - host: '127.0.0.1', - port: address.port, - username: 'fixture', - password: 'fixture' - }) - }) - const sftp = await new Promise((resolve, reject) => - client.sftp((error, value) => (error ? reject(error) : resolve(value))) - ) - return { - sftp, - counts: () => ({ reads, closes }), - close: async () => { - sftp.end() - client.end() - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ) - } - } -} - -describe('real SFTP directory packet contract', () => { - it('stops server enumeration after one packet and closes the remote handle', async () => { - const fixture = await createServer() - try { - for await (const entry of readDirectoryEntriesViaSftp(fixture.sftp, '/early')) { - expect(entry.filename).toBe('file-0.txt') - break - } - expect(fixture.counts()).toEqual({ reads: 1, closes: 1 }) - expect(await readSftpDirectory(fixture.sftp, '/complete')).toHaveLength(1000) - expect(fixture.counts()).toEqual({ reads: 12, closes: 2 }) - } finally { - await fixture.close() - } - }) -}) diff --git a/src/main/providers/ssh-directory-streaming.test.ts b/src/main/providers/ssh-directory-streaming.test.ts deleted file mode 100644 index 8d0d55a13c64..000000000000 --- a/src/main/providers/ssh-directory-streaming.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SFTPWrapper } from 'ssh2' -import { readDirectoryEntriesViaSftp } from './ssh-filesystem-provider-sftp' -import { readSftpDirectory } from './ssh-sftp-directory-listing' - -function fixture(packets: string[][]) { - let next = 0 - const handle = Buffer.from('directory') - const sftp = { - opendir: vi.fn((_path, callback) => callback(null, handle)), - readdir: vi.fn((_handle, callback) => - next < packets.length - ? callback( - null, - packets[next++].map((filename) => ({ - filename, - attrs: { isSymbolicLink: () => false, isDirectory: () => false } - })) - ) - : callback(Object.assign(new Error('EOF'), { code: 1 })) - ), - close: vi.fn((_handle, callback) => callback(null)) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements all three handle operations used by the reader. - return { mock: sftp, sftp: sftp as unknown as SFTPWrapper } -} - -describe('SFTP directory handle ownership', () => { - it('does not request later packets after a consumer stops', async () => { - const { sftp, mock } = fixture([['first'], ['second']]) - for await (const entry of readDirectoryEntriesViaSftp(sftp, '/folder')) { - expect(entry.filename).toBe('first') - break - } - expect(mock.readdir).toHaveBeenCalledTimes(1) - expect(mock.close).toHaveBeenCalledTimes(1) - }) - - it('continues through empty filtered packets until the protocol EOF error', async () => { - const { sftp, mock } = fixture([[], ['.', '..'], ['visible']]) - expect((await readSftpDirectory(sftp, '/folder')).map((entry) => entry.name)).toEqual([ - 'visible' - ]) - expect(mock.close).toHaveBeenCalledTimes(1) - }) - - it('rejects capacity before fetching the remaining million-entry directory', async () => { - const name = 'x'.repeat(1000) - const { sftp, mock } = fixture(Array.from({ length: 1000 }, () => Array(100).fill(name))) - await expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('too large') - expect(mock.readdir.mock.calls.length).toBeLessThan(50) - expect(mock.close).toHaveBeenCalledTimes(1) - }) - - it('closes after cancellation between packets', async () => { - const { sftp, mock } = fixture([['first'], ['second']]) - const controller = new AbortController() - await expect( - (async () => { - for await (const _entry of readDirectoryEntriesViaSftp(sftp, '/folder', { - signal: controller.signal - })) { - controller.abort(new Error('closed')) - } - })() - ).rejects.toThrow('closed') - expect(mock.close).toHaveBeenCalledTimes(1) - }) -}) - -it('bounds a silent CLOSE after early stop and ignores its late callback', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['first']]) - let lateClose: (() => void) | undefined - mock.close.mockImplementation((_handle, callback) => { - lateClose = () => callback(null) - }) - const iterator = readDirectoryEntriesViaSftp(sftp, '/folder') - await iterator.next() - const stopped = iterator.return(undefined) - await vi.advanceTimersByTimeAsync(5000) - await stopped - expect(mock.close).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - lateClose?.() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('preserves cancellation when CLOSE never acknowledges', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['first'], ['second']]) - mock.close.mockImplementation(() => {}) - const controller = new AbortController() - const iterator = readDirectoryEntriesViaSftp(sftp, '/folder', { signal: controller.signal }) - await iterator.next() - controller.abort(new Error('original cancellation')) - const rejected = expect(iterator.next()).rejects.toThrow('original cancellation') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(mock.close).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('closes a late OPENDIR handle even after cancellation settled', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([]) - let lateOpen: (() => void) | undefined - mock.opendir.mockImplementation((_path, callback) => { - lateOpen = () => callback(null, Buffer.from('late-handle')) - }) - mock.close.mockImplementation(() => {}) - const controller = new AbortController() - const pending = readDirectoryEntriesViaSftp(sftp, '/folder', { - signal: controller.signal - }).next() - const rejected = expect(pending).rejects.toThrow('original cancellation') - controller.abort(new Error('original cancellation')) - await vi.advanceTimersByTimeAsync(5000) - await rejected - lateOpen?.() - expect(mock.close).toHaveBeenCalledWith(Buffer.from('late-handle'), expect.any(Function)) - await vi.advanceTimersByTimeAsync(5000) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('preserves capacity failure when CLOSE never acknowledges', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['x'.repeat(5 * 1024 * 1024)]]) - mock.close.mockImplementation(() => {}) - const rejected = expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('too large') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(mock.close).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('rejects explicit failed CLOSE and retires the persistent channel', async () => { - const { sftp, mock } = fixture([]) - const end = vi.fn() - Object.assign(sftp, { end }) - const failure = new Error('CLOSE failed') - mock.close.mockImplementation((_handle, callback) => callback(failure)) - await expect(readSftpDirectory(sftp, '/folder')).rejects.toBe(failure) - expect(end).toHaveBeenCalledOnce() -}) - -it('rejects EOF CLOSE timeout without claiming acknowledgement; late callback stays inert', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([]) - const end = vi.fn() - Object.assign(sftp, { end }) - let lateClose: (() => void) | undefined - mock.close.mockImplementation((_handle, callback) => { - lateClose = () => callback(null) - }) - const rejected = expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('CLOSE timed out') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(end).toHaveBeenCalledOnce() - lateClose?.() - expect(end).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it.each(['EOF callback', 'CLOSE callback'])( - 'preserves abort reason during final %s', - async (boundary) => { - const { sftp, mock } = fixture([]) - const controller = new AbortController() - const reason = new Error('original final cancellation') - if (boundary === 'EOF callback') { - mock.readdir.mockImplementation((_handle, callback) => { - callback(null, false) - controller.abort(reason) - }) - } else { - mock.close.mockImplementation((_handle, callback) => { - controller.abort(reason) - callback(null) - }) - } - await expect(readSftpDirectory(sftp, '/folder', { signal: controller.signal })).rejects.toBe( - reason - ) - } -) - -it('preserves a consumer capacity failure when CLOSE explicitly fails', async () => { - const { sftp, mock } = fixture([['x'.repeat(5 * 1024 * 1024)]]) - mock.close.mockImplementation((_handle, callback) => callback(new Error('cleanup failed'))) - await expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('too large') -}) - -it('keeps the persistent channel for acknowledged normal EOF', async () => { - const { sftp } = fixture([['visible']]) - const end = vi.fn() - Object.assign(sftp, { end }) - await expect(readSftpDirectory(sftp, '/folder')).resolves.toHaveLength(1) - expect(end).not.toHaveBeenCalled() -}) - -it('cancels a silent symlink STAT and closes its directory handle', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['linked']]) - mock.readdir.mockImplementationOnce((_handle, callback) => - callback(null, [ - { - filename: 'linked', - attrs: { isSymbolicLink: () => true, isDirectory: () => false } - } - ]) - ) - const stat = vi.fn() - Object.assign(sftp, { stat }) - const controller = new AbortController() - const reason = new Error('canceled during STAT') - const pending = readSftpDirectory(sftp, '/folder', { signal: controller.signal }) - await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) - const rejected = expect(pending).rejects.toBe(reason) - controller.abort(reason) - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(mock.close).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/providers/ssh-file-listing.ts b/src/main/providers/ssh-file-listing.ts deleted file mode 100644 index c2439bae7199..000000000000 --- a/src/main/providers/ssh-file-listing.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { sshFilesystemListingParams } from './ssh-filesystem-listing-params' -import { FileInventoryBudget, FILE_INVENTORY_MAX_BYTES } from '../../shared/file-inventory-budget' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IFilesystemProvider } from './types' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' - -export async function listSshFiles( - mux: SshChannelMultiplexer, - rootPath: string, - options?: Parameters[1] -): Promise { - const params = sshFilesystemListingParams(rootPath, options) - // Why #7721: the signal lets a workspace switch send rpc.cancel so the - // relay aborts the full-tree scan instead of stacking abandoned scans - // that starve interactive fs.readDir/fs.stat on the shared SSH channel. - // Why streamable: a monorepo listing serializes past the relay's 1 MiB control lane, and the - // lane it demotes to is refused under unrelated producer load. Opting in moves it to the bulk - // lane in chunks; an old relay ignores the flag and answers plainly, which the reader detects - // by the sentinel marker being absent. - const result = await requestGitStreamable(mux, 'fs.listFiles', params, { - signal: options?.signal, - maxResponseBytes: - options?.maxResults !== undefined ? 16 * 1024 * 1024 : FILE_INVENTORY_MAX_BYTES - }) - if (!Array.isArray(result) || result.some((path) => typeof path !== 'string')) { - throw new Error('Invalid remote file listing') - } - if (options?.maxResults !== undefined && result.length > options.maxResults) { - throw new Error('Remote file listing exceeds the requested capacity') - } - if (options?.maxResults === undefined && options?.searchQuery === undefined) { - const budget = new FileInventoryBudget() - for (const path of result) { - budget.record(path) - } - } - return result -} diff --git a/src/main/providers/ssh-filesystem-doc-preview.test.ts b/src/main/providers/ssh-filesystem-doc-preview.test.ts deleted file mode 100644 index 540b655cba7a..000000000000 --- a/src/main/providers/ssh-filesystem-doc-preview.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { DocPreviewFileAccessRequest } from '../../shared/doc-preview-file-access' -import { readSshDocPreviewFile } from './ssh-filesystem-doc-preview' - -const request: DocPreviewFileAccessRequest = { - boundaryPath: '/repo', - entryPath: '/repo/docs/index.html', - implicitRootPath: '/repo/docs', - authorizedRootPaths: ['/repo/assets'], - targetPath: '/repo/docs/app.js', - maxTextBytes: 1024, - maxBinaryBytes: 2048 -} - -describe('readSshDocPreviewFile', () => { - it('delegates canonical authorization and reading to the relay host', async () => { - const mux = { request: vi.fn().mockResolvedValue({ content: 'ok', isBinary: false }) } - - await expect(readSshDocPreviewFile(mux as never, request)).resolves.toEqual({ - content: 'ok', - isBinary: false - }) - expect(mux.request).toHaveBeenCalledWith('fs.readDocPreview', request) - }) - - it('fails closed when the relay predates scoped preview reads', async () => { - const mux = { - request: vi - .fn() - .mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - } - - await expect(readSshDocPreviewFile(mux as never, request)).rejects.toThrow( - 'Reconnect the SSH target' - ) - }) -}) diff --git a/src/main/providers/ssh-filesystem-doc-preview.ts b/src/main/providers/ssh-filesystem-doc-preview.ts deleted file mode 100644 index 004a09f92db1..000000000000 --- a/src/main/providers/ssh-filesystem-doc-preview.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { - DocPreviewFileAccessRequest, - DocPreviewFileAccessResult -} from '../../shared/doc-preview-file-access' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' - -const REMOTE_DOC_PREVIEW_UPDATE_REQUIRED = - 'Secure document previews require a newer SSH relay. Reconnect the SSH target and try again.' - -export async function readSshDocPreviewFile( - mux: SshChannelMultiplexer, - request: DocPreviewFileAccessRequest -): Promise { - try { - return (await mux.request('fs.readDocPreview', request)) as DocPreviewFileAccessResult - } catch (error) { - if (isMethodNotFoundError(error)) { - throw new Error(REMOTE_DOC_PREVIEW_UPDATE_REQUIRED) - } - throw error - } -} diff --git a/src/main/providers/ssh-filesystem-download.test.ts b/src/main/providers/ssh-filesystem-download.test.ts deleted file mode 100644 index 1017f1056088..000000000000 --- a/src/main/providers/ssh-filesystem-download.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { DirectoryTransferBudget } from '../ssh/ssh-directory-transfer-budget' -import { withSftpDirectoryHandles } from './sftp-directory-test-fixture' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { downloadFolderViaSftp } from './ssh-filesystem-download' - -type SftpEntryKind = 'directory' | 'file' | 'symlink' | 'fifo' - -function sftpStats(kind: SftpEntryKind) { - return { - size: 0, - mode: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: () => kind === 'directory', - isFile: () => kind === 'file', - isSymbolicLink: () => kind === 'symlink', - isFIFO: () => kind === 'fifo', - isSocket: () => false, - isBlockDevice: () => false, - isCharacterDevice: () => false - } -} - -function sftpEntry(filename: string, kind: SftpEntryKind) { - return { filename, longname: filename, attrs: sftpStats(kind) } -} - -describe('downloadFolderViaSftp', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function createDestination(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - roots.push(root) - return join(root, 'src') - } - - it('uses exclusive local reservations instead of guessing destination case sensitivity', async () => { - const destination = await createDestination() - let transferCount = 0 - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('A.txt', 'file'), sftpEntry('a.txt', 'file')]) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => { - transferCount += 1 - if (transferCount === 1) { - void writeFile(join(destination, 'a.txt'), 'claimed').then(() => callback()) - return - } - callback() - }), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Remote entries map to the same local name 'a.txt'") - expect(sftp.fastGet).toHaveBeenCalledTimes(1) - }) - - it('rejects special remote entries before SFTP tries to open them', async () => { - const destination = await createDestination() - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('build.pipe', 'fifo')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Cannot download unsupported remote entry 'build.pipe'") - expect(sftp.fastGet).not.toHaveBeenCalled() - }) - - it('rejects a file symlink that could escape the selected remote tree', async () => { - const destination = await createDestination() - const sftp = { - stat: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'file')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('creds', 'symlink')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Cannot download symbolic link 'creds'") - // The link target could be /etc/passwd; rejecting from directory-entry - // metadata means it is never followed with stat or opened by fastGet. - expect(sftp.stat).toHaveBeenCalledTimes(1) - expect(sftp.fastGet).not.toHaveBeenCalled() - }) - - it('sanitizes extended Windows device names in nested entries', async () => { - const destination = await createDestination() - const records = vi.spyOn(DirectoryTransferBudget.prototype, 'record') - const releases = vi.spyOn(DirectoryTransferBudget.prototype, 'release') - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('CONIN$', 'file'), sftpEntry('COM¹.txt', 'file')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Remote entries map to the same local name 'download'") - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(records).toHaveBeenCalledTimes(3) - expect(releases).toHaveBeenCalledWith( - records.mock.results.reduce((bytes, record) => bytes + record.value, 0), - 3 - ) - records.mockRestore() - releases.mockRestore() - }) - - it('preserves legal POSIX backslashes in opaque SFTP child names', async () => { - const destination = await createDestination() - const sourcePath = '/remote/parent\\literal' - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('..\\secret.txt', 'file')]) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => - callback() - ), - end: vi.fn() - } - - await downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - sourcePath, - destination, - { - windowsRemotePaths: false - } - ) - - expect(sftp.fastGet).toHaveBeenCalledWith( - '/remote/parent\\literal/..\\secret.txt', - join(destination, '.._secret.txt'), - expect.any(Function) - ) - }) - - it('rejects Windows-path traversal names when the remote host is Windows', async () => { - const destination = await createDestination() - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('..\\secret.txt', 'file')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - 'C:/remote/src', - destination, - { - windowsRemotePaths: true - } - ) - ).rejects.toThrow("Invalid remote directory entry '..\\secret.txt'") - expect(sftp.fastGet).not.toHaveBeenCalled() - }) - - it('waits for active SFTP file handles to quiesce when canceled', async () => { - const destination = await createDestination() - let fastGetCallback: ((error?: Error) => void) | undefined - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('first.txt', 'file'), sftpEntry('second.txt', 'file')]) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (error?: Error) => void) => { - fastGetCallback = callback - }), - end: vi.fn() - } - const controller = new AbortController() - - const result = downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - '/remote/src', - destination, - { - signal: controller.signal - } - ) - await vi.waitFor(() => expect(sftp.fastGet).toHaveBeenCalledTimes(1)) - controller.abort(new Error('renderer closed')) - - let settled = false - void result.then( - () => { - settled = true - }, - () => { - settled = true - } - ) - await Promise.resolve() - expect(settled).toBe(false) - fastGetCallback?.(new Error('channel closed')) - - await expect(result).rejects.toThrow('renderer closed') - expect(sftp.fastGet).toHaveBeenCalledTimes(1) - expect(sftp.end).toHaveBeenCalledTimes(1) - }) - - it('cancels a pending SFTP directory read', async () => { - const destination = await createDestination() - let readDirCallback: ((error?: Error) => void) | undefined - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (error?: Error) => void) => { - readDirCallback = callback - }), - fastGet: vi.fn(), - end: vi.fn() - } - const controller = new AbortController() - - const result = downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - '/remote/src', - destination, - { - signal: controller.signal - } - ) - await vi.waitFor(() => expect(sftp.readdir).toHaveBeenCalledTimes(1)) - controller.abort(new Error('renderer closed')) - readDirCallback?.(new Error('channel closed')) - - await expect(result).rejects.toThrow('renderer closed') - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.end).toHaveBeenCalledTimes(1) - }) -}) - -it.each(['EOF', 'CLOSE'])( - 'does not create an empty destination when cancelled at %s', - async (boundary) => { - const root = await mkdtemp(join(tmpdir(), 'orca-sftp-final-abort-')) - const destination = join(root, 'target') - const controller = new AbortController() - const reason = new Error('cancel at final boundary') - const sftp = withSftpDirectoryHandles({ - stat: (_path: string, callback: (error: undefined, stats: unknown) => void) => - callback(undefined, sftpStats('directory')), - readdir: (_path: string, callback: (error: undefined, entries: unknown) => void) => - callback(undefined, []), - end: vi.fn() - }) - sftp.readdir = (_handle, callback) => { - callback(Object.assign(new Error('EOF'), { code: 1 }), []) - } - if (boundary === 'EOF') { - sftp.readdir = (_handle, callback) => { - callback(Object.assign(new Error('EOF'), { code: 1 }), []) - controller.abort(reason) - } - } else { - sftp.close = (_handle, callback) => { - controller.abort(reason) - callback(null) - } - } - try { - await expect( - downloadFolderViaSftp(async () => sftp, '/empty', destination, { - signal: controller.signal - }) - ).rejects.toBe(reason) - const { access } = await import('node:fs/promises') - await expect(access(destination)).rejects.toMatchObject({ code: 'ENOENT' }) - } finally { - await rm(root, { recursive: true, force: true }) - } - } -) diff --git a/src/main/providers/ssh-filesystem-download.ts b/src/main/providers/ssh-filesystem-download.ts deleted file mode 100644 index 97ef78e1a916..000000000000 --- a/src/main/providers/ssh-filesystem-download.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { DirectoryTransferBudget } from '../ssh/ssh-directory-transfer-budget' -import { mkdir, open } from 'node:fs/promises' -import { join } from 'node:path' -import type { FileEntryWithStats, SFTPWrapper } from 'ssh2' - -import { - isWindowsAbsolutePathLike, - normalizeRuntimePathSeparators -} from '../../shared/cross-platform-path' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { - fastGetViaSftp, - readDirectoryEntriesViaSftp, - statViaSftp -} from './ssh-filesystem-provider-sftp' - -export type SftpFactory = (options?: { signal?: AbortSignal }) => Promise - -/** When known, windowsRemotePaths drives remote path joining; omit uses path-shape heuristics. */ -export type FolderDownloadOptions = { signal?: AbortSignal; windowsRemotePaths?: boolean } - -const DOWNLOAD_UNAVAILABLE_MESSAGE = - 'Remote folder download is unavailable. Reconnect the SSH target and retry.' - -function isEEXIST(error: unknown): boolean { - return ( - error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST' - ) -} - -async function reserveLocalFile(localPath: string, localName: string): Promise { - try { - const handle = await open(localPath, 'wx') - await handle.close() - } catch (error) { - if (isEEXIST(error)) { - throw new Error(`Remote entries map to the same local name '${localName}'`) - } - throw error - } -} - -function remotePathsAreWindows(sourceDir: string, windowsRemotePaths?: boolean): boolean { - // Why: remote path rules belong to the SSH host. Prefer the known host platform - // and only fall back to string shape when the provider could not supply it. - if (windowsRemotePaths !== undefined) { - return windowsRemotePaths - } - return isWindowsAbsolutePathLike(sourceDir) -} - -function joinSftpChildPath( - sourceDir: string, - childName: string, - windowsRemotePaths?: boolean -): string { - const windowsPath = remotePathsAreWindows(sourceDir, windowsRemotePaths) - if ( - !childName || - childName === '.' || - childName === '..' || - childName.includes('/') || - (windowsPath && childName.includes('\\')) - ) { - throw new Error(`Invalid remote directory entry '${childName}'`) - } - const normalizedSource = windowsPath ? normalizeRuntimePathSeparators(sourceDir) : sourceDir - return `${normalizedSource.replace(/\/+$/g, '')}/${childName}` -} - -function classifySftpEntry(entry: FileEntryWithStats): 'directory' | 'file' { - if (entry.attrs.isSymbolicLink()) { - // Why: following either file or directory links can escape the selected tree; - // local symlink creation is also not portable across Orca's supported hosts. - throw new Error(`Cannot download symbolic link '${entry.filename}'`) - } - if (entry.attrs.isDirectory()) { - return 'directory' - } - if (entry.attrs.isFile()) { - return 'file' - } - throw new Error(`Cannot download unsupported remote entry '${entry.filename}'`) -} - -async function downloadDirectoryTree( - sftp: SFTPWrapper, - sourceDir: string, - destinationDir: string, - signal?: AbortSignal, - windowsRemotePaths?: boolean, - budget = new DirectoryTransferBudget(), - depth = 0 -): Promise { - signal?.throwIfAborted() - const usedLocalNames = new Set() - const plannedEntries: { - remoteName: string - kind: 'directory' | 'file' - localName: string - }[] = [] - let retainedBytes = budget.record([sourceDir, destinationDir], depth) - let retainedEntries = 1 - try { - for await (const entry of readDirectoryEntriesViaSftp(sftp, sourceDir, { signal })) { - const localName = sanitizeLocalDownloadFilename(entry.filename) - retainedBytes += budget.record([sourceDir, destinationDir, entry.filename, localName], depth) - retainedEntries++ - if (usedLocalNames.has(localName)) { - throw new Error(`Remote entries map to the same local name '${localName}'`) - } - usedLocalNames.add(localName) - plannedEntries.push({ - remoteName: entry.filename, - kind: classifySftpEntry(entry), - localName - }) - } - - signal?.throwIfAborted() - await mkdir(destinationDir, { recursive: false }) - signal?.throwIfAborted() - for (const { remoteName, kind, localName } of plannedEntries) { - signal?.throwIfAborted() - const remotePath = joinSftpChildPath(sourceDir, remoteName, windowsRemotePaths) - const localPath = join(destinationDir, localName) - if (kind === 'directory') { - await downloadDirectoryTree( - sftp, - remotePath, - localPath, - signal, - windowsRemotePaths, - budget, - depth + 1 - ) - continue - } - // Why: filesystem semantics belong to the selected volume, not the host OS; - // an exclusive placeholder prevents case/Unicode aliases from overwriting. - await reserveLocalFile(localPath, localName) - await fastGetViaSftp(sftp, remotePath, localPath, { signal }) - } - signal?.throwIfAborted() - } finally { - budget.release(retainedBytes, retainedEntries) - } -} - -export async function downloadFileViaSftp( - createSftp: SftpFactory | undefined, - sourcePath: string, - destinationPath: string -): Promise { - if (!createSftp) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - const sftp = await createSftp() - try { - await fastGetViaSftp(sftp, sourcePath, destinationPath) - } finally { - sftp.end() - } -} - -export async function downloadFolderViaSftp( - createSftp: SftpFactory | undefined, - sourcePath: string, - destinationPath: string, - options?: FolderDownloadOptions -): Promise { - if (!createSftp) { - throw new Error(DOWNLOAD_UNAVAILABLE_MESSAGE) - } - const signal = options?.signal - signal?.throwIfAborted() - const sftp = await createSftp({ signal }) - let ended = false - const endSftp = (): void => { - if (!ended) { - ended = true - try { - sftp.end() - } catch { - // Why: cleanup is best-effort and must not mask the transfer or abort error. - } - } - } - signal?.addEventListener('abort', endSftp, { once: true }) - try { - const rootStats = await statViaSftp(sftp, sourcePath, { signal }) - if (!rootStats.isDirectory()) { - throw new Error('Cannot download a file as a folder') - } - await downloadDirectoryTree( - sftp, - sourcePath, - destinationPath, - signal, - options?.windowsRemotePaths - ) - signal?.throwIfAborted() - } finally { - signal?.removeEventListener('abort', endSftp) - endSftp() - } -} diff --git a/src/main/providers/ssh-filesystem-file-upload.ts b/src/main/providers/ssh-filesystem-file-upload.ts deleted file mode 100644 index 6fa83145d692..000000000000 --- a/src/main/providers/ssh-filesystem-file-upload.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import { uploadFile as uploadFileViaSftp } from '../ssh/sftp-upload' -import type { FileUploadSession } from './types' - -export type SftpFactory = () => Promise - -export type SshRawTransferOptions = { - downloadFile?: (sourcePath: string, destinationPath: string) => Promise - openFileUploadSession?: () => Promise - writeBuffer?: ( - remotePath: string, - contents: Buffer, - options: { append: boolean; exclusive: boolean } - ) => Promise -} - -export async function openSshFileUploadSession( - createSftp?: SftpFactory, - rawTransfer?: SshRawTransferOptions -): Promise { - if (rawTransfer?.openFileUploadSession) { - return rawTransfer.openFileUploadSession() - } - if (!createSftp) { - throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') - } - const sftp = await createSftp() - return { - // Why: one session covers the whole import so normal SSH keeps its prior - // channel count even when a directory contains many files. - uploadFile: (sourcePath, destinationPath, options) => - uploadFileViaSftp(sftp, sourcePath, destinationPath, options), - close: () => sftp.end() - } -} diff --git a/src/main/providers/ssh-filesystem-listing-params.ts b/src/main/providers/ssh-filesystem-listing-params.ts deleted file mode 100644 index 61bb308c3baf..000000000000 --- a/src/main/providers/ssh-filesystem-listing-params.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { IFilesystemProvider } from './types' - -export function sshFilesystemListingParams( - rootPath: string, - options?: Parameters[1] -): Record { - return { - rootPath, - ...(options?.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }), - ...(options?.excludePaths?.length ? { excludePaths: options.excludePaths } : {}), - ...(options?.maxResults === undefined ? {} : { maxResults: options.maxResults }), - ...(options?.searchQuery === undefined ? {} : { searchQuery: options.searchQuery }), - ...(options?.includeIgnored === undefined ? {} : { includeIgnored: options.includeIgnored }), - ...(options?.followSymlinks === undefined ? {} : { followSymlinks: options.followSymlinks }) - } -} diff --git a/src/main/providers/ssh-filesystem-path-existence.ts b/src/main/providers/ssh-filesystem-path-existence.ts deleted file mode 100644 index 08570fc17b3c..000000000000 --- a/src/main/providers/ssh-filesystem-path-existence.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { isENOENT } from '../ipc/filesystem-path-containment' -import { - capturePathExistence, - requirePathExistenceResults, - validatePathExistenceBatch, - type PathExistenceResult -} from '../../shared/path-existence-batch' -import { probeSshPathExistenceBatchCapability } from './ssh-filesystem-provider-capabilities' - -export async function readSshPathExistenceBatch( - mux: SshChannelMultiplexer, - paths: string[], - stat: (path: string) => Promise -): Promise { - validatePathExistenceBatch(paths) - if (await probeSshPathExistenceBatchCapability(mux)) { - try { - return requirePathExistenceResults( - await mux.request('fs.pathsExist', { filePaths: paths }), - paths.length - ) - } catch (error) { - if (!isMethodNotFoundError(error)) { - throw error - } - } - } - return Promise.all( - paths.map((path) => - capturePathExistence(async () => { - try { - await stat(path) - return true - } catch (error) { - if (isENOENT(error)) { - return false - } - throw error - } - }) - ) - ) -} diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.test.ts b/src/main/providers/ssh-filesystem-provider-capabilities.test.ts deleted file mode 100644 index f00f8a965d07..000000000000 --- a/src/main/providers/ssh-filesystem-provider-capabilities.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - probeSshQuickOpenSearchCapability, - probeSshRangedReadCapability -} from './ssh-filesystem-provider-capabilities' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' - -describe('SSH Quick Open capability probe', () => { - it.each([1, 2, 3, 4])( - 'separates query and modern capabilities on version %s', - async (version) => { - const mux = new SshChannelMultiplexer({ write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }) - const request = vi - .spyOn(mux, 'request') - .mockResolvedValue({ quickOpenSearchVersion: version }) - try { - await expect(probeSshQuickOpenSearchCapability(mux, undefined, 1)).resolves.toBe(true) - await expect(probeSshQuickOpenSearchCapability(mux)).resolves.toBe(version >= 3) - expect(request).toHaveBeenCalledTimes(1) - expect(request).toHaveBeenCalledWith('fs.getCapabilities', undefined, { - signal: undefined, - timeoutMs: 5_000 - }) - } finally { - mux.dispose() - } - } - ) - - it('lets callers treat a missing capability as legacy', async () => { - const mux = { - request: vi - .fn() - .mockRejectedValue( - Object.assign(new Error('method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - } - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('does not downgrade transport failures to legacy behavior', async () => { - const mux = { request: vi.fn().mockRejectedValue(new Error('connection closed')) } - await expect(probeSshQuickOpenSearchCapability(mux as never)).rejects.toThrow( - 'connection closed' - ) - }) -}) - -describe('SSH filesystem capability document', () => { - // fs.getCapabilities answers every feature at once. Probing per feature would - // spend an extra round trip per connection for a document already in hand. - it('is fetched once for every feature probe on a connection', async () => { - const mux = { - request: vi.fn().mockResolvedValue({ quickOpenSearchVersion: 3, rangedReadVersion: 1 }) - } - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('reads each feature independently off the shared document', async () => { - const mux = { request: vi.fn().mockResolvedValue({ quickOpenSearchVersion: 3 }) } - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(false) - }) - - // A transport failure is not a verdict about the host, so it must not be the - // cached answer for the rest of the connection's life. - it('retries after a failed fetch instead of caching the failure', async () => { - const mux = { - request: vi - .fn() - .mockRejectedValueOnce(new Error('connection closed')) - .mockResolvedValue({ rangedReadVersion: 1 }) - } - await expect(probeSshRangedReadCapability(mux as never)).rejects.toThrow('connection closed') - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - // One document now backs every feature, so a caller walking away from its own - // probe must not evict the fetch a second feature is still waiting on -- - // otherwise an aborted quick-open probe costs the ranged-read probe a round - // trip, and a caller that keeps aborting re-fetches forever. - it('keeps the in-flight document when one caller aborts its own probe', async () => { - let settle: (value: Record) => void = () => {} - const mux = { - request: vi - .fn() - .mockImplementationOnce( - () => - new Promise>((resolve) => { - settle = resolve - }) - ) - .mockResolvedValue({ rangedReadVersion: 1 }) - } - const controller = new AbortController() - const abandoned = probeSshQuickOpenSearchCapability(mux as never, controller.signal) - const patient = probeSshRangedReadCapability(mux as never) - controller.abort() - await expect(abandoned).rejects.toThrow('client_disconnected') - - settle({ rangedReadVersion: 1 }) - await expect(patient).resolves.toBe(true) - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('retries when the fetch fails after its only waiter aborts', async () => { - let rejectFetch: (error: Error) => void = () => {} - const mux = { - request: vi - .fn() - .mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectFetch = reject - }) - ) - .mockResolvedValue({ rangedReadVersion: 1 }) - } - const controller = new AbortController() - const abandoned = probeSshRangedReadCapability(mux as never, controller.signal) - controller.abort() - await expect(abandoned).rejects.toThrow('client_disconnected') - - rejectFetch(new Error('connection closed')) - await new Promise((resolve) => setImmediate(resolve)) - - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('treats a non-object response as no capabilities', async () => { - const mux = { request: vi.fn().mockResolvedValue('yes') } - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(false) - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.ts b/src/main/providers/ssh-filesystem-provider-capabilities.ts deleted file mode 100644 index 800999c30535..000000000000 --- a/src/main/providers/ssh-filesystem-provider-capabilities.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { QUICK_OPEN_SEARCH_VERSION } from '../../shared/quick-open-path-search' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter' - -/** `null` means the host has no capability document at all: it predates - * `fs.getCapabilities`, or answered with something that is not an object. - * Either way every feature reads as unsupported. */ -type RelayFsCapabilities = Record | null - -const capabilitiesByMux = new WeakMap>() - -/** One `fs.getCapabilities` per multiplexer, shared by every feature probe -- - * the response is a single document, so fetching it once per feature would - * spend a round trip for nothing. Keyed by the multiplexer, which is replaced - * on reconnect, so a host upgraded behind a reconnect is re-probed. - * - * A failed fetch is evicted so the next probe retries; a resolved one is not, - * because a relay does not gain methods without a new connection. */ -function readSshFsCapabilities( - mux: SshChannelMultiplexer, - signal?: AbortSignal -): Promise { - const cached = capabilitiesByMux.get(mux) - const probe = - cached ?? - mux - .request('fs.getCapabilities', undefined, { timeoutMs: 5_000 }) - .then((result) => - typeof result === 'object' && result !== null ? (result as Record) : null - ) - .catch((error) => { - if (isMethodNotFoundError(error)) { - return null - } - throw error - }) - if (!cached) { - capabilitiesByMux.set(mux, probe) - void probe.catch(() => { - if (capabilitiesByMux.get(mux) === probe) { - capabilitiesByMux.delete(mux) - } - }) - } - return waitForSshCapabilityProbe(probe, signal) -} - -export function probeSshQuickOpenSearchCapability( - mux: SshChannelMultiplexer, - signal?: AbortSignal, - minimumVersion = QUICK_OPEN_SEARCH_VERSION -): Promise { - return readSshFsCapabilities(mux, signal).then( - (capabilities) => - typeof capabilities?.quickOpenSearchVersion === 'number' && - capabilities.quickOpenSearchVersion >= minimumVersion - ) -} - -export function probeSshRangedReadCapability( - mux: SshChannelMultiplexer, - signal?: AbortSignal -): Promise { - return readSshFsCapabilities(mux, signal).then( - (capabilities) => capabilities?.rangedReadVersion === 1 - ) -} - -export function probeSshPathExistenceBatchCapability(mux: SshChannelMultiplexer): Promise { - return readSshFsCapabilities(mux).then( - (capabilities) => capabilities?.pathExistenceBatchVersion === 1 - ) -} diff --git a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts b/src/main/providers/ssh-filesystem-provider-download-folder.test.ts deleted file mode 100644 index 9166fde83b44..000000000000 --- a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { withSftpDirectoryHandles } from './sftp-directory-test-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { SshFilesystemProvider } from './ssh-filesystem-provider' - -type SftpEntryKind = 'directory' | 'file' | 'symlink' - -function sftpStats(kind: SftpEntryKind) { - return { - size: 0, - mode: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: () => kind === 'directory', - isFile: () => kind === 'file', - isSymbolicLink: () => kind === 'symlink', - isFIFO: () => false, - isSocket: () => false, - isBlockDevice: () => false, - isCharacterDevice: () => false - } -} - -function sftpEntry(filename: string, kind: SftpEntryKind) { - return { filename, longname: filename, attrs: sftpStats(kind) } -} - -function createMockMux() { - return { - request: vi.fn(), - onNotification: vi.fn(() => () => {}) - } -} - -describe('SshFilesystemProvider downloadFolder', () => { - let mux: ReturnType - let provider: SshFilesystemProvider - const localDownloadRoots: string[] = [] - - beforeEach(() => { - mux = createMockMux() - provider = new SshFilesystemProvider('conn-1', mux as never) - }) - - afterEach(async () => { - await Promise.all( - localDownloadRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) - ) - }) - - it('omits folder capability without SFTP while retaining raw file download', async () => { - const downloadFile = vi.fn().mockResolvedValue(undefined) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { downloadFile }) - - expect(provider.downloadFolder).toBeUndefined() - await provider.downloadFile('/remote/report.pdf', '/downloads/report.pdf') - expect(downloadFile).toHaveBeenCalledWith('/remote/report.pdf', '/downloads/report.pdf') - }) - - it('downloads a recursive tree through one SFTP session', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const sftp = { - stat: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'file')) - ), - readdir: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback( - undefined, - remotePath === '/remote/src' - ? [sftpEntry('index.ts', 'file'), sftpEntry('lib', 'directory')] - : [sftpEntry('a.ts', 'file')] - ) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => - callback() - ), - end: vi.fn() - } - const createSftp = vi.fn(async () => withSftpDirectoryHandles(sftp)) - provider = new SshFilesystemProvider('conn-1', mux as never, createSftp) - const destination = join(root, 'src') - - await provider.downloadFolder!('/remote/src', destination) - - expect(createSftp).toHaveBeenCalledTimes(1) - expect(sftp.fastGet.mock.calls.map(([source, local]) => [source, local])).toEqual([ - ['/remote/src/index.ts', join(destination, 'index.ts')], - ['/remote/src/lib/a.ts', join(destination, 'lib', 'a.ts')] - ]) - await expect(stat(join(destination, 'lib'))).resolves.toMatchObject({}) - expect(sftp.end).toHaveBeenCalledTimes(1) - expect(mux.request).not.toHaveBeenCalled() - }) - - it('rejects directory symlinks without following them', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const sftp = { - stat: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('linked-dir', 'symlink')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture multiplexer implements the request and notification methods used by this provider. - provider = new SshFilesystemProvider('conn-1', mux as never, async () => - withSftpDirectoryHandles(sftp) - ) - - await expect(provider.downloadFolder!('/remote/src', join(root, 'src'))).rejects.toThrow( - "Cannot download symbolic link 'linked-dir'" - ) - - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.end).toHaveBeenCalledTimes(1) - }) - - it('rejects remote names that sanitize to the same local filename', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('a:b.txt', 'file'), sftpEntry('a?b.txt', 'file')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture multiplexer implements the request and notification methods used by this provider. - provider = new SshFilesystemProvider('conn-1', mux as never, async () => - withSftpDirectoryHandles(sftp) - ) - - await expect(provider.downloadFolder!('/remote/src', join(root, 'src'))).rejects.toThrow( - "Remote entries map to the same local name 'a_b.txt'" - ) - - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.end).toHaveBeenCalledTimes(1) - }) - - it('cancels a pending SFTP session open', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const createSftp = vi.fn( - async (options?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(new Error('open canceled')), { - once: true - }) - }) - ) - provider = new SshFilesystemProvider('conn-1', mux as never, createSftp) - const controller = new AbortController() - - const result = provider.downloadFolder!('/remote/src', join(root, 'src'), { - signal: controller.signal - }) - await vi.waitFor(() => expect(createSftp).toHaveBeenCalledTimes(1)) - controller.abort(new Error('renderer closed')) - - await expect(result).rejects.toThrow('open canceled') - expect(createSftp).toHaveBeenCalledWith({ signal: controller.signal }) - expect(mux.request).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-range.test.ts b/src/main/providers/ssh-filesystem-provider-range.test.ts deleted file mode 100644 index 3dee006d59d0..000000000000 --- a/src/main/providers/ssh-filesystem-provider-range.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import { FileRangeReadUnsupportedError } from './filesystem-provider-contract' -import { FileRangeReadRequestError, MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' - -function methodNotFound(): Error { - const err = new Error('Method not found: fs.readFileRange') as Error & { code?: number } - err.code = -32601 - return err -} - -function providerWith(request: ReturnType): SshFilesystemProvider { - const mux = { request, onNotification: () => () => {} } as unknown as SshChannelMultiplexer - return new SshFilesystemProvider('conn-1', mux) -} - -describe('SshFilesystemProvider.readFileRange', () => { - it('decodes the base64 window the relay returned', async () => { - const payload = Buffer.from('hello') - const request = vi.fn().mockResolvedValue({ - base64: payload.toString('base64'), - bytesRead: payload.length - }) - const result = await providerWith(request).readFileRange('/x.jsonl', 7, 5) - expect(result.bytes).toEqual(payload) - expect(result.bytesRead).toBe(5) - expect(request).toHaveBeenCalledWith( - 'fs.readFileRange', - { filePath: '/x.jsonl', position: 7, length: 5 }, - undefined - ) - }) - - it('reports a short window as read without complaint', async () => { - const payload = Buffer.from('tail') - const request = vi.fn().mockResolvedValue({ - base64: payload.toString('base64'), - bytesRead: payload.length - }) - const result = await providerWith(request).readFileRange('/x.jsonl', 0, 64) - expect(result.bytesRead).toBe(4) - expect(result.bytes).toEqual(payload) - }) - - it('forwards an abort signal to the multiplexer', async () => { - const controller = new AbortController() - const request = vi.fn().mockResolvedValue({ base64: '', bytesRead: 0 }) - await providerWith(request).readFileRange('/x.jsonl', 0, 4, { signal: controller.signal }) - expect(request).toHaveBeenCalledWith('fs.readFileRange', expect.anything(), { - signal: controller.signal - }) - }) - - // Throwing beats a whole-file fallback here: a tailing caller issues several - // reads per snapshot, so falling back per call is quadratic on a growing file. - it('throws a typed unsupported error against a relay without the method', async () => { - const request = vi.fn().mockRejectedValue(methodNotFound()) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 4)).rejects.toBeInstanceOf( - FileRangeReadUnsupportedError - ) - }) - - it('propagates a non-capability error unchanged', async () => { - const request = vi.fn().mockRejectedValue(new Error('EACCES: permission denied')) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 4)).rejects.toThrow(/EACCES/) - }) - - // The client runs the host's own validator, so a request the host would - // refuse fails as a typed error instead of an opaque -32000 a round trip later. - describe('request validation', () => { - it.each([ - ['a negative position', -1, 4], - ['a fractional position', 0.5, 4], - ['a zero length', 0, 0], - ['a negative length', 0, -1], - ['an over-cap length', 0, MAX_FILE_RANGE_READ_BYTES + 1], - ['a window past safe-integer offsets', Number.MAX_SAFE_INTEGER, 2] - ])('rejects %s without a round trip', async (_label, position, length) => { - const request = vi.fn() - await expect( - providerWith(request).readFileRange('/x.jsonl', position, length) - ).rejects.toBeInstanceOf(FileRangeReadRequestError) - expect(request).not.toHaveBeenCalled() - }) - }) - - // The remote is untrusted for framing: a count disagreeing with the payload - // would shift every downstream offset while looking like a success. - it('rejects a byte count that disagrees with the payload', async () => { - const request = vi.fn().mockResolvedValue({ - base64: Buffer.from('ab').toString('base64'), - bytesRead: 9 - }) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 10)).rejects.toThrow( - /inconsistent byte count/ - ) - }) - - it('rejects a byte count larger than the requested length', async () => { - const payload = Buffer.from('abcdef') - const request = vi.fn().mockResolvedValue({ - base64: payload.toString('base64'), - bytesRead: payload.length - }) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 2)).rejects.toThrow( - /inconsistent byte count/ - ) - }) - - it('rejects a malformed response', async () => { - const request = vi.fn().mockResolvedValue({ bytesRead: 3 }) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 3)).rejects.toThrow( - /malformed response/ - ) - }) -}) - -describe('SshFilesystemProvider.supportsFileRangeRead', () => { - it('is true when the relay advertises the capability', async () => { - const request = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) - await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(true) - }) - - it('is false when the relay omits it', async () => { - const request = vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) - await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(false) - }) - - it('is false when the relay predates fs.getCapabilities', async () => { - const request = vi.fn().mockRejectedValue(methodNotFound()) - await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(false) - }) - - it('probes once per connection and caches the answer', async () => { - const request = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) - const provider = providerWith(request) - await provider.supportsFileRangeRead() - await provider.supportsFileRangeRead() - expect(request).toHaveBeenCalledTimes(1) - }) - - // The cache is keyed by the multiplexer, which is replaced on reconnect, so a - // second connection must not inherit the first host's answer. - it('does not share the answer across connections', async () => { - const legacy = vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) - const current = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) - await expect(providerWith(legacy).supportsFileRangeRead()).resolves.toBe(false) - await expect(providerWith(current).supportsFileRangeRead()).resolves.toBe(true) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts deleted file mode 100644 index 82defb9d0cce..000000000000 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshMultiplexerRequestOptions } from '../ssh/ssh-channel-multiplexer' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import { SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS } from '../ssh/ssh-file-stream-inactivity-deadline' -import { publishSystemResume, publishSystemSuspend } from '../system-power-lifecycle' - -type MockMultiplexer = { - request: ReturnType - _response: ReturnType - notify: ReturnType - onNotification: ReturnType - onNotificationByMethod: ReturnType - onDispose: ReturnType - dispose: ReturnType - isDisposed: ReturnType - _emitMethod: (method: string, params: Record) => void - _listenerCount: () => number -} - -function createMockMux(): MockMultiplexer { - const response = vi.fn().mockResolvedValue(undefined) - const methodHandlers = new Map) => void>>() - const disposeHandlers = new Set<(reason: 'shutdown' | 'connection_lost') => void>() - return { - _response: response, - request: vi.fn( - async ( - method: string, - params?: Record, - options?: SshMultiplexerRequestOptions - ) => { - const result: unknown = await response(method, params) - options?.beforeResolve?.(result) - return result - } - ), - notify: vi.fn(), - onNotification: vi.fn(), - onNotificationByMethod: vi.fn( - (method: string, handler: (params: Record) => void) => { - let set = methodHandlers.get(method) - if (!set) { - set = new Set() - methodHandlers.set(method, set) - } - set.add(handler) - return () => set!.delete(handler) - } - ), - onDispose: vi.fn((handler: (reason: 'shutdown' | 'connection_lost') => void) => { - disposeHandlers.add(handler) - return () => { - disposeHandlers.delete(handler) - } - }), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false), - _emitMethod: (method, params) => { - const set = methodHandlers.get(method) - if (set) { - for (const handler of Array.from(set)) { - handler(params) - } - } - }, - _listenerCount: () => - disposeHandlers.size + - [...methodHandlers.values()].reduce((count, handlers) => count + handlers.size, 0) - } -} - -describe('SshFilesystemProvider readFile streaming', () => { - let mux: MockMultiplexer - let provider: SshFilesystemProvider - - beforeEach(() => { - publishSystemResume() - mux = createMockMux() - provider = new SshFilesystemProvider('conn-1', mux as never) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('returns empty metadata and releases its stream listeners', async () => { - mux._response.mockResolvedValue({ totalSize: 0, isBinary: false, empty: true }) - const result = await provider.readFile('/home/user/empty.txt') - expect(result).toEqual({ content: '', isBinary: false }) - expect(mux._listenerCount()).toBe(0) - }) - - it('streams via fs.readFileStream and reassembles utf-8 text', async () => { - const text = 'hello world' - const totalSize = Buffer.byteLength(text, 'utf-8') - mux._response.mockImplementation(async (method: string) => { - if (method !== 'fs.readFileStream') { - throw new Error(`unexpected method ${method}`) - } - // The relay schedules chunks after publishing metadata. - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.from(text, 'utf-8').toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: false, - chunkEncoding: 'base64', - resultEncoding: 'utf-8' - } - }) - - const result = await provider.readFile('/home/user/file.txt') - expect(mux.request.mock.calls[0]?.slice(0, 2)).toEqual([ - 'fs.readFileStream', - { - filePath: '/home/user/file.txt', - flowControl: 'ack' - } - ]) - expect(result).toEqual({ content: text, isBinary: false }) - }) - - it('falls back to legacy fs.readFile on -32601 method-not-found', async () => { - const legacyResult = { content: 'legacy', isBinary: false } - mux._response.mockImplementation(async (method: string) => { - if (method === 'fs.readFileStream') { - const err = new Error('Method not found') as Error & { code: number } - err.code = -32601 - throw err - } - if (method === 'fs.readFile') { - return legacyResult - } - throw new Error(`unexpected method ${method}`) - }) - - const result = await provider.readFile('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.readFile', { filePath: '/home/user/file.txt' }) - expect(result).toEqual(legacyResult) - }) - - it('rejects when chunk arrives out of order', async () => { - const totalSize = 256 * 1024 * 2 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 1, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/out-of-order/i) - }) - - it('rejects when totalSize exceeds client cap without allocating', async () => { - mux._response.mockResolvedValue({ - streamId: 1, - totalSize: 51 * 1024 * 1024, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/exceeds client cap/i) - expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 1 }) - }) - - it('applies a caller binary cap before allocating the stream buffer', async () => { - mux._response.mockResolvedValue({ - streamId: 2, - totalSize: 2, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - }) - - await expect(provider.readFile('/home/x.bin', { maxBinaryBytes: 1 })).rejects.toThrow( - /exceeds client cap/i - ) - expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 2 }) - }) - - it('rejects on fs.streamError notification', async () => { - const totalSize = 1024 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamError', { - streamId: 7, - code: 'ENOENT', - message: 'gone' - }) - }) - return { - streamId: 7, - totalSize, - isBinary: false, - chunkEncoding: 'base64', - resultEncoding: 'utf-8' - } - }) - await expect(provider.readFile('/home/x.txt')).rejects.toThrow(/gone/) - }) - - it('cancels and cleans up a stream that stalls after metadata', async () => { - vi.useFakeTimers() - mux._response.mockResolvedValue({ - streamId: 9, - totalSize: 1, - isBinary: false, - resultEncoding: 'utf-8' - }) - - const read = provider.readFile('/home/x.txt') - const rejection = expect(read).rejects.toThrow(/file stream stalled/i) - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS) - - await rejection - expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 9 }) - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('keeps a long stream alive while chunks continue arriving', async () => { - vi.useFakeTimers() - const chunkSize = 256 * 1024 - mux._response.mockResolvedValue({ - streamId: 10, - totalSize: chunkSize + 1, - isBinary: true, - resultEncoding: 'base64' - }) - - const read = provider.readFile('/home/x.bin') - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - mux._emitMethod('fs.streamChunk', { - streamId: 10, - seq: 0, - data: Buffer.alloc(chunkSize).toString('base64') - }) - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - mux._emitMethod('fs.streamChunk', { - streamId: 10, - seq: 1, - data: Buffer.alloc(1).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 10 }) - - await expect(read).resolves.toEqual({ - content: Buffer.alloc(chunkSize + 1).toString('base64'), - isBinary: true - }) - expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('grants an active stream a fresh inactivity window after system resume', async () => { - vi.useFakeTimers() - mux._response.mockResolvedValue({ - streamId: 11, - totalSize: 1, - isBinary: false, - resultEncoding: 'utf-8' - }) - - const read = provider.readFile('/home/x.txt') - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - publishSystemSuspend() - vi.setSystemTime(Date.now() + 60 * 60_000) - publishSystemResume() - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - - expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) - mux._emitMethod('fs.streamChunk', { - streamId: 11, - seq: 0, - data: Buffer.from('x').toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 11 }) - await expect(read).resolves.toEqual({ content: 'x', isBinary: false }) - - publishSystemResume() - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('keeps metadata received during suspend paused until resume', async () => { - vi.useFakeTimers() - mux._response.mockResolvedValue({ - streamId: 12, - totalSize: 1, - isBinary: false, - resultEncoding: 'utf-8' - }) - - publishSystemSuspend() - const read = provider.readFile('/home/x.txt') - const outcome = vi.fn() - void read.then( - () => outcome('resolved'), - () => outcome('rejected') - ) - await vi.advanceTimersByTimeAsync(60 * 60_000) - - expect(outcome).not.toHaveBeenCalled() - expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) - publishSystemResume() - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - mux._emitMethod('fs.streamChunk', { - streamId: 12, - seq: 0, - data: Buffer.from('x').toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 12 }) - - await expect(read).resolves.toEqual({ content: 'x', isBinary: false }) - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('rejects on chunk count mismatch at streamEnd', async () => { - const totalSize = 256 * 1024 * 3 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i) - }) - - it('rejects a short final chunk instead of zero-filling the buffer', async () => { - // Two declared chunks, but the final one delivers a single byte. The chunk - // count matches (2), so the old code resolved with a zero-filled tail. The - // exact-length check must reject this. - const totalSize = 256 * 1024 * 2 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 1, - data: Buffer.alloc(1).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) - }) - - it('rejects a short non-final chunk before later chunks arrive', async () => { - const totalSize = 256 * 1024 * 2 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.alloc(1).toString('base64') - }) - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 1, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts b/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts deleted file mode 100644 index 375ed34848d1..000000000000 --- a/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { registerSshFilesystemWatch, type WatchRegistration } from './ssh-filesystem-provider-watch' - -describe('registerSshFilesystemWatch waiters', () => { - it('removes ten thousand cancelled callers while one registration anchor remains', async () => { - let resolveWatch: () => void = () => undefined - const mux = { - request: vi.fn( - () => - new Promise((resolve) => { - resolveWatch = resolve - }) - ), - notify: vi.fn() - } as unknown as SshChannelMultiplexer - const registrations = new Map() - const watch = (signal?: AbortSignal) => - registerSshFilesystemWatch({ - mux, - registrations, - rootPath: '/home/user/project', - callback: vi.fn(), - signal, - disposed: () => false - }) - const anchor = watch() - const controllers = Array.from({ length: 10_000 }, () => new AbortController()) - const cancelled = controllers.map((controller) => - watch(controller.signal).catch((error) => error) - ) - - for (const controller of controllers) { - controller.abort() - } - await Promise.all(cancelled) - - expect(registrations.values().next().value?.setupWaiters.waiterCount).toBe(1) - resolveWatch() - await expect(anchor).resolves.toEqual(expect.any(Function)) - expect(registrations.values().next().value?.setupWaiters.waiterCount).toBe(0) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts deleted file mode 100644 index 95ef73632eb7..000000000000 --- a/src/main/providers/ssh-filesystem-provider-watch.ts +++ /dev/null @@ -1,262 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' - -let nextRemoteWatchId = 1 - -export type WatchRegistration = { - rootPath: string - callbacks: Set<(events: FsChangeEvent[]) => void> - terminalCallbacks: Map<(events: FsChangeEvent[]) => void, (error: Error) => void> - setupWaiters: PromiseSettlementWaiters - setupAbortController: AbortController - remoteWatchId: number | null - ready: boolean - stopping: boolean - unwatchSent: boolean -} - -function createWatchAbortError(): Error { - const error = new Error('Request "fs.watch" was cancelled') as Error & { name: string } - error.name = 'AbortError' - return error -} - -async function awaitSetupWithOptionalAbort( - setupWaiters: PromiseSettlementWaiters, - signal?: AbortSignal -): Promise { - await setupWaiters.wait({ - signal, - createAbortError: createWatchAbortError - }) -} - -export async function registerSshFilesystemWatch(args: { - mux: SshChannelMultiplexer - disposed: () => boolean - registrations: Map - rootPath: string - callback: (events: FsChangeEvent[]) => void - onTerminalError?: (error: Error) => void - signal?: AbortSignal -}): Promise<() => void> { - if (args.disposed()) { - throw new Error('SSH filesystem provider disposed') - } - if (args.signal?.aborted) { - throw createWatchAbortError() - } - - const rootKey = sshFilesystemWatchKey(args.rootPath) - let registration = args.registrations.get(rootKey) - if (registration) { - if (registration.stopping) { - throw createWatchAbortError() - } - registration.callbacks.add(args.callback) - registration.terminalCallbacks.set(args.callback, args.onTerminalError ?? (() => undefined)) - try { - // Why: each caller may leave a shared setup independently; registration - // ownership decides whether the physical relay request should stop. - await awaitSetupWithOptionalAbort(registration.setupWaiters, args.signal) - assertActiveWatch(args, registration) - return createSshFilesystemWatchUnsubscribe(args, registration) - } catch (error) { - releaseSshFilesystemWatchCallback(args, registration) - throw error - } - } - - const callbacks = new Set<(events: FsChangeEvent[]) => void>([args.callback]) - const setupAbortController = new AbortController() - const remoteWatchId = nextRemoteWatchId++ - if (!Number.isSafeInteger(nextRemoteWatchId)) { - nextRemoteWatchId = 1 - } - registration = { - rootPath: args.rootPath, - callbacks, - terminalCallbacks: new Map([[args.callback, args.onTerminalError ?? (() => undefined)]]), - setupAbortController, - remoteWatchId, - ready: false, - stopping: false, - unwatchSent: false, - setupWaiters: new PromiseSettlementWaiters(Promise.resolve()) - } - const createdRegistration = registration - // Why: the shared registration, not its first caller, owns relay setup. - // This keeps a same-root joiner alive when the original caller disconnects. - const setupPromise = args.mux - .request( - 'fs.watch', - { rootPath: args.rootPath, watchId: remoteWatchId }, - { signal: setupAbortController.signal } - ) - .then( - () => { - createdRegistration.ready = true - if ( - createdRegistration.stopping || - createdRegistration.callbacks.size === 0 || - args.disposed() || - args.registrations.get(rootKey) !== createdRegistration - ) { - if (args.registrations.get(rootKey) === createdRegistration) { - args.registrations.delete(rootKey) - } - sendSshFilesystemUnwatchOnce(args.mux, createdRegistration) - } - }, - (error) => { - if (args.registrations.get(rootKey) === createdRegistration) { - args.registrations.delete(rootKey) - } - throw error - } - ) - registration.setupWaiters = new PromiseSettlementWaiters(setupPromise) - args.registrations.set(rootKey, registration) - try { - await awaitSetupWithOptionalAbort(registration.setupWaiters, args.signal) - assertActiveWatch(args, registration) - return createSshFilesystemWatchUnsubscribe(args, registration) - } catch (error) { - releaseSshFilesystemWatchCallback(args, registration) - throw error - } -} - -export function notifySshFilesystemUnwatch(mux: SshChannelMultiplexer, rootPath: string): void { - try { - mux.notify('fs.unwatch', { rootPath }) - } catch {} -} - -export async function closeSshFilesystemWatch( - mux: SshChannelMultiplexer, - registrations: Map, - rootPath: string -): Promise { - const rootKey = sshFilesystemWatchKey(rootPath) - try { - await mux.request('fs.unwatchAndWait', { rootPath }) - } catch (error) { - if (!isMethodNotFoundError(error)) { - throw error - } - throw new Error('Remote watcher teardown is unavailable. Reconnect the SSH target and retry.') - } - registrations.get(rootKey)?.callbacks.clear() - registrations.get(rootKey)?.terminalCallbacks.clear() - registrations.delete(rootKey) -} - -export function failSshFilesystemWatchRegistration( - registrations: Map, - rootPath: string, - remoteWatchId: number, - error: Error -): void { - const rootKey = sshFilesystemWatchKey(rootPath) - const registration = registrations.get(rootKey) - if (!registration || registration.remoteWatchId !== remoteWatchId) { - return - } - registrations.delete(rootKey) - registration.stopping = true - registration.unwatchSent = true - const terminalCallbacks = Array.from(registration.terminalCallbacks.values()) - registration.callbacks.clear() - registration.terminalCallbacks.clear() - for (const onTerminalError of terminalCallbacks) { - try { - onTerminalError(error) - } catch (callbackError) { - console.error('[ssh-fs] terminal watch callback failed', callbackError) - } - } -} - -function assertActiveWatch( - args: { - disposed: () => boolean - registrations: Map - rootPath: string - }, - registration: WatchRegistration -): void { - if ( - args.disposed() || - args.registrations.get(sshFilesystemWatchKey(args.rootPath)) !== registration - ) { - throw new Error('SSH filesystem provider disposed') - } -} - -function createSshFilesystemWatchUnsubscribe( - args: { - mux: SshChannelMultiplexer - registrations: Map - rootPath: string - callback: (events: FsChangeEvent[]) => void - }, - registration: WatchRegistration -): () => void { - return () => { - releaseSshFilesystemWatchCallback(args, registration) - } -} - -function releaseSshFilesystemWatchCallback( - args: { - mux: SshChannelMultiplexer - registrations: Map - rootPath: string - callback: (events: FsChangeEvent[]) => void - }, - registration: WatchRegistration -): void { - registration.callbacks.delete(args.callback) - registration.terminalCallbacks.delete(args.callback) - const rootKey = sshFilesystemWatchKey(args.rootPath) - if (registration.callbacks.size > 0 || args.registrations.get(rootKey) !== registration) { - return - } - registration.stopping = true - if (registration.ready) { - args.registrations.delete(rootKey) - sendSshFilesystemUnwatchOnce(args.mux, registration) - } else { - // Keep the cancelling registration indexed until its request settles so a - // new same-root setup cannot race an old late success and be unwatched. - registration.setupAbortController.abort() - } -} - -export function stopSshFilesystemWatchRegistration( - mux: SshChannelMultiplexer, - registration: WatchRegistration -): void { - registration.stopping = true - registration.setupAbortController.abort() - sendSshFilesystemUnwatchOnce(mux, registration) -} - -function sendSshFilesystemUnwatchOnce( - mux: SshChannelMultiplexer, - registration: WatchRegistration -): void { - if (registration.unwatchSent) { - return - } - registration.unwatchSent = true - notifySshFilesystemUnwatch(mux, registration.rootPath) -} - -function sshFilesystemWatchKey(rootPath: string): string { - return normalizeRuntimePathForComparison(rootPath) -} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts deleted file mode 100644 index 0bd3a4464928..000000000000 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ /dev/null @@ -1,907 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { PassThrough } from 'node:stream' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' - -type MockMultiplexer = { - request: ReturnType - notify: ReturnType - onNotification: ReturnType - onNotificationByMethod: ReturnType - onDispose: ReturnType - dispose: ReturnType - isDisposed: ReturnType - _methodHandlers: Map) => void>> - _emitMethod: (method: string, params: Record) => void -} - -function createMockMux(): MockMultiplexer { - const methodHandlers = new Map) => void>>() - return { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - onNotificationByMethod: vi.fn( - (method: string, handler: (params: Record) => void) => { - let set = methodHandlers.get(method) - if (!set) { - set = new Set() - methodHandlers.set(method, set) - } - set.add(handler) - return () => set!.delete(handler) - } - ), - onDispose: vi.fn(() => () => {}), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false), - _methodHandlers: methodHandlers, - _emitMethod: (method, params) => { - const set = methodHandlers.get(method) - if (set) { - for (const handler of Array.from(set)) { - handler(params) - } - } - } - } -} - -describe('SshFilesystemProvider', () => { - let mux: MockMultiplexer - let provider: SshFilesystemProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshFilesystemProvider('conn-1', mux as never) - }) - - describe('readDir', () => { - it('sends fs.readDir request', async () => { - const entries = [ - { name: 'src', isDirectory: true, isSymlink: false }, - { name: 'README.md', isDirectory: false, isSymlink: false } - ] - mux.request.mockResolvedValue(entries) - - const result = await provider.readDir('/home/user/project') - expect(mux.request).toHaveBeenCalledWith('fs.readDirBounded', { - dirPath: '/home/user/project', - __streamResponse: true - }) - expect(result).toEqual(entries) - }) - }) - - describe('readTerminalArtifact', () => { - it('sends fs.readTerminalArtifact request with verification metadata', async () => { - mux.request.mockResolvedValue({ content: '{}', isBinary: false }) - - await expect( - provider.readTerminalArtifact('/tmp/result.json', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).resolves.toEqual({ content: '{}', isBinary: false }) - expect(mux.request).toHaveBeenCalledWith('fs.readTerminalArtifact', { - filePath: '/tmp/result.json', - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - }) - - it('asks the user to reconnect when the relay is too old for artifact reads', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - - await expect( - provider.readTerminalArtifact('/tmp/result.json', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).rejects.toThrow('Reconnect the SSH target') - }) - }) - - describe('writeFile', () => { - it('sends fs.writeFile request', async () => { - await provider.writeFile('/home/user/file.txt', 'new content') - expect(mux.request).toHaveBeenCalledWith('fs.writeFile', { - filePath: '/home/user/file.txt', - content: 'new content' - }) - }) - }) - - describe('writeTerminalArtifact', () => { - it('returns the verified artifact write stat', async () => { - mux.request.mockResolvedValue({ stat: { type: 'file', size: 2, mtime: 3 } }) - - await expect( - provider.writeTerminalArtifact('/tmp/result.json', '{}', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).resolves.toEqual({ type: 'file', size: 2, mtime: 3 }) - expect(mux.request).toHaveBeenCalledWith('fs.writeTerminalArtifact', { - filePath: '/tmp/result.json', - content: '{}', - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - }) - - it('asks the user to reconnect when the relay is too old for artifact writes', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - - await expect( - provider.writeTerminalArtifact('/tmp/result.json', '{}', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).rejects.toThrow('Reconnect the SSH target') - }) - }) - - describe('getTempDir', () => { - it('reads and caches the remote temp directory from the relay', async () => { - mux.request.mockResolvedValue('/var/folders/remote') - - await expect(provider.getTempDir()).resolves.toBe('/var/folders/remote') - await expect(provider.getTempDir()).resolves.toBe('/var/folders/remote') - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith('fs.tempDir', {}) - }) - - it('falls back to /tmp when connected to an older relay', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - - await expect(provider.getTempDir()).resolves.toBe('/tmp') - }) - }) - - describe('writeFileBase64', () => { - it('writes decoded bytes through SFTP', async () => { - const written: Buffer[] = [] - const writeStream = { - on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - off: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - end: vi.fn((buffer: Buffer) => { - written.push(buffer) - const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] - closeHandler?.() - }), - destroy: vi.fn() - } - const sftp = { - createWriteStream: vi.fn(() => writeStream), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await provider.writeFileBase64('/home/user/logo.png', 'cG5n') - - expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'wx' }) - expect(written).toEqual([Buffer.from('png')]) - expect(sftp.end).toHaveBeenCalled() - expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything()) - }) - - it('writes decoded bytes through raw transfer when provided', async () => { - const writeBuffer = vi.fn().mockResolvedValue(undefined) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { writeBuffer }) - - await provider.writeFileBase64('/home/user/logo.png', 'cG5n') - await provider.writeFileBase64Chunk('/home/user/logo.png', 'bW9yZQ==', true) - - expect(writeBuffer).toHaveBeenNthCalledWith(1, '/home/user/logo.png', Buffer.from('png'), { - append: false, - exclusive: true - }) - expect(writeBuffer).toHaveBeenNthCalledWith(2, '/home/user/logo.png', Buffer.from('more'), { - append: true, - exclusive: false - }) - }) - - it('can append decoded chunks through SFTP', async () => { - const writeStream = { - on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - off: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - end: vi.fn((_buffer: Buffer) => { - const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] - closeHandler?.() - }), - destroy: vi.fn() - } - const sftp = { - createWriteStream: vi.fn(() => writeStream), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await provider.writeFileBase64Chunk('/home/user/logo.png', 'cG5n', true) - - expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'a' }) - expect(sftp.end).toHaveBeenCalled() - }) - }) - - describe('downloadFile', () => { - it('downloads raw bytes through raw transfer when provided', async () => { - const downloadFile = vi.fn().mockResolvedValue(undefined) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { downloadFile }) - - await provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') - - expect(downloadFile).toHaveBeenCalledWith('/home/user/archive.zip', '/tmp/archive.zip') - }) - - it('downloads raw bytes through SFTP and closes the session', async () => { - const sftp = { - fastGet: vi.fn( - (_source: string, _destination: string, callback: (err?: Error | null) => void) => - callback() - ), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') - - expect(sftp.fastGet).toHaveBeenCalledWith( - '/home/user/archive.zip', - '/tmp/archive.zip', - expect.any(Function) - ) - expect(sftp.end).toHaveBeenCalled() - }) - - it('closes the SFTP session when raw download fails', async () => { - const sftp = { - fastGet: vi.fn( - (_source: string, _destination: string, callback: (err?: Error | null) => void) => - callback(new Error('download failed')) - ), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await expect( - provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') - ).rejects.toThrow('download failed') - - expect(sftp.end).toHaveBeenCalled() - }) - }) - - describe('openFileUploadSession', () => { - it('uses one SFTP session for multiple files and closes it once', async () => { - const createWriteStream = vi.fn(() => { - const stream = new PassThrough() - stream.resume() - return stream - }) - const sftp = { createWriteStream, end: vi.fn() } - const createSftp = vi.fn().mockResolvedValue(sftp) - provider = new SshFilesystemProvider('conn-1', mux as never, createSftp as never) - const dir = mkdtempSync(join(tmpdir(), 'orca-sftp-upload-session-')) - const first = join(dir, 'first.txt') - const second = join(dir, 'second.txt') - writeFileSync(first, 'first') - writeFileSync(second, 'second') - - try { - const session = await provider.openFileUploadSession() - await session.uploadFile(first, '/remote/first.txt', { exclusive: true }) - await session.uploadFile(second, '/remote/second.txt', { exclusive: true }) - session.close() - - expect(createSftp).toHaveBeenCalledOnce() - expect(createWriteStream).toHaveBeenCalledTimes(2) - expect(sftp.end).toHaveBeenCalledOnce() - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('uses the transport-owned upload session when provided', async () => { - const uploadSession = { uploadFile: vi.fn(), close: vi.fn() } - const openFileUploadSession = vi.fn().mockResolvedValue(uploadSession) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { - openFileUploadSession - }) - - await expect(provider.openFileUploadSession()).resolves.toBe(uploadSession) - expect(openFileUploadSession).toHaveBeenCalledOnce() - }) - }) - - describe('createDirNoClobber', () => { - it('sends fs.createDirNoClobber request', async () => { - await provider.createDirNoClobber('/home/user/new-dir') - - expect(mux.request).toHaveBeenCalledWith('fs.createDirNoClobber', { - dirPath: '/home/user/new-dir' - }) - }) - }) - - describe('stat', () => { - it('sends fs.stat request', async () => { - const statResult = { size: 1024, type: 'file', mtime: 1234567890 } - mux.request.mockResolvedValue(statResult) - - const result = await provider.stat('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.stat', { filePath: '/home/user/file.txt' }) - expect(result).toEqual(statResult) - }) - }) - - describe('lstat', () => { - it('sends fs.lstat request', async () => { - const statResult = { size: 12, type: 'symlink', mtime: 1234567890 } - mux.request.mockResolvedValue(statResult) - - const result = await provider.lstat('/home/user/link.txt') - expect(mux.request).toHaveBeenCalledWith('fs.lstat', { filePath: '/home/user/link.txt' }) - expect(result).toEqual(statResult) - }) - - it('falls back to SFTP lstat when connected to an older relay', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - const sftp = { - lstat: vi.fn((_path: string, callback: (err: Error | undefined, stats: unknown) => void) => - callback(undefined, { - size: 12, - mtime: 1234567, - isDirectory: () => false, - isSymbolicLink: () => true - }) - ), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await expect(provider.lstat('/home/user/link.txt')).resolves.toEqual({ - size: 12, - type: 'symlink', - mtime: 1234567000 - }) - expect(sftp.lstat).toHaveBeenCalledWith('/home/user/link.txt', expect.any(Function)) - expect(sftp.end).toHaveBeenCalled() - }) - }) - - it('scanWorkspaceSpace sends an abortable bulk scan request', async () => { - const result = { - sizeBytes: 1024, - skippedEntryCount: 0, - topLevelItems: [], - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - } - const controller = new AbortController() - mux.request.mockResolvedValue(result) - - await expect( - provider.scanWorkspaceSpace('/home/user/project', { signal: controller.signal }) - ).resolves.toBe(result) - expect(mux.request).toHaveBeenCalledWith( - 'fs.workspaceSpaceScan', - { rootPath: '/home/user/project' }, - { signal: controller.signal, timeoutMs: 130000 } - ) - }) - - it('deletePath sends fs.deletePath request', async () => { - await provider.deletePath('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.deletePath', { targetPath: '/home/user/file.txt' }) - }) - - it('createFile sends fs.createFile request', async () => { - await provider.createFile('/home/user/new.txt') - expect(mux.request).toHaveBeenCalledWith('fs.createFile', { filePath: '/home/user/new.txt' }) - }) - - it('createDir sends fs.createDir request', async () => { - await provider.createDir('/home/user/newdir') - expect(mux.request).toHaveBeenCalledWith('fs.createDir', { dirPath: '/home/user/newdir' }) - }) - - it('rename sends fs.rename request', async () => { - await provider.rename('/home/old.txt', '/home/new.txt') - expect(mux.request).toHaveBeenCalledWith('fs.rename', { - oldPath: '/home/old.txt', - newPath: '/home/new.txt' - }) - }) - - it('renameNoClobber sends fs.renameNoClobber request', async () => { - await provider.renameNoClobber('/home/old.txt', '/home/new.txt') - expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { - oldPath: '/home/old.txt', - newPath: '/home/new.txt' - }) - }) - - it('renameNoClobber fails closed when the relay lacks safe rename support', async () => { - mux.request.mockRejectedValueOnce( - Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - - await expect(provider.renameNoClobber('/home/old.txt', '/home/new.txt')).rejects.toThrow( - 'Remote safe rename is unavailable' - ) - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { - oldPath: '/home/old.txt', - newPath: '/home/new.txt' - }) - }) - - it('copy sends fs.copy request', async () => { - await provider.copy('/home/src.txt', '/home/dst.txt') - expect(mux.request).toHaveBeenCalledWith('fs.copy', { - source: '/home/src.txt', - destination: '/home/dst.txt' - }) - }) - - it('realpath sends fs.realpath request', async () => { - mux.request.mockResolvedValue('/home/user/real/path') - const result = await provider.realpath('/home/user/link') - expect(result).toBe('/home/user/real/path') - }) - - it('search sends fs.search request with all options', async () => { - const searchResult = { files: [], totalMatches: 0, truncated: false } - mux.request.mockResolvedValue(searchResult) - - const opts = { - query: 'TODO', - rootPath: '/home/user/project', - caseSensitive: true - } - const result = await provider.search(opts) - expect(mux.request).toHaveBeenCalledWith('fs.search', opts, { signal: undefined }) - expect(result).toEqual(searchResult) - }) - - // Why #12547: a monorepo listing does not fit one control-lane frame, so the request opts into - // response streaming. An old relay ignores `__streamResponse` and answers plainly, which is the - // plain-array case each of these asserts. - it('listFiles sends a streamable fs.listFiles request', async () => { - mux.request.mockResolvedValue(['src/index.ts', 'package.json']) - const result = await provider.listFiles('/home/user/project') - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - __streamResponse: true - }) - expect(result).toEqual(['src/index.ts', 'package.json']) - }) - - it('listFiles forwards listing and query options', async () => { - mux.request.mockResolvedValue([]) - await provider.listFiles('/home/user/project', { - excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000, - searchQuery: 'target' - }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000, - searchQuery: 'target', - __streamResponse: true - }) - }) - - it('listFiles omits excludePaths when empty', async () => { - mux.request.mockResolvedValue([]) - await provider.listFiles('/home/user/project', { excludePaths: [] }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - __streamResponse: true - }) - }) - - it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => { - mux.request.mockResolvedValue([]) - const controller = new AbortController() - await provider.listFiles('/home/user/project', { signal: controller.signal }) - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/home/user/project', __streamResponse: true }, - { signal: controller.signal, timeoutMs: undefined } - ) - }) - - describe('watch', () => { - it('uses a registration-owned cancellation signal for the mux fs.watch request', async () => { - mux.request.mockResolvedValue(undefined) - const controller = new AbortController() - const callback = vi.fn() - - await provider.watch('/home/user/project', callback, { signal: controller.signal }) - - expect(mux.request).toHaveBeenCalledWith( - 'fs.watch', - { rootPath: '/home/user/project', watchId: expect.any(Number) }, - { signal: expect.any(AbortSignal) } - ) - const physicalSignal = mux.request.mock.calls[0][2]?.signal - expect(physicalSignal).not.toBe(controller.signal) - expect(physicalSignal?.aborted).toBe(false) - }) - - it('rejects promptly when the watch signal aborts during setup', async () => { - let resolveWatch: () => void = () => {} - mux.request.mockImplementationOnce( - (_method, _params, options?: { signal?: AbortSignal }) => - new Promise((resolve, reject) => { - resolveWatch = resolve - options?.signal?.addEventListener( - 'abort', - () => { - const error = new Error('Request "fs.watch" was cancelled') as Error & { - name: string - } - error.name = 'AbortError' - reject(error) - }, - { once: true } - ) - }) - ) - const controller = new AbortController() - const pendingWatch = provider.watch('/home/user/project', vi.fn(), { - signal: controller.signal - }) - - controller.abort() - - await expect(pendingWatch).rejects.toMatchObject({ name: 'AbortError' }) - expect(mux.request.mock.calls[0][2]?.signal?.aborted).toBe(true) - resolveWatch() - }) - - it('keeps shared setup alive when only its first caller aborts', async () => { - let resolveWatch: () => void = () => {} - let physicalSignal: AbortSignal | undefined - mux.request.mockImplementationOnce( - (_method, _params, options?: { signal?: AbortSignal }) => - new Promise((resolve) => { - resolveWatch = resolve - physicalSignal = options?.signal - }) - ) - const firstController = new AbortController() - const first = vi.fn() - const second = vi.fn() - - const firstWatch = provider.watch('/home/user/project', first, { - signal: firstController.signal - }) - const secondWatch = provider.watch('/home/user/project', second) - firstController.abort() - - await expect(firstWatch).rejects.toMatchObject({ name: 'AbortError' }) - expect(physicalSignal?.aborted).toBe(false) - - resolveWatch() - const unsubscribeSecond = await secondWatch - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - - expect(first).not.toHaveBeenCalled() - expect(second).toHaveBeenCalledWith(events) - unsubscribeSecond() - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { - rootPath: '/home/user/project' - }) - }) - - it('forwards fs.changed notifications to watch callback', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - - expect(callback).toHaveBeenCalledWith(events) - }) - - it('invalidates only the matching watch generation after relay recovery terminates', async () => { - const firstTerminal = vi.fn() - const secondTerminal = vi.fn() - await provider.watch('/home/user/project', vi.fn(), { - onTerminalError: firstTerminal - }) - await provider.watch('/home/user/project', vi.fn(), { - onTerminalError: secondTerminal - }) - const notifHandler = mux.onNotification.mock.calls[0][0] - const firstWatchId = mux.request.mock.calls[0][1].watchId as number - - notifHandler('fs.watchFailed', { - rootPath: '/home/user/project', - watchId: firstWatchId + 1, - message: 'stale failure' - }) - expect(firstTerminal).not.toHaveBeenCalled() - - notifHandler('fs.watchFailed', { - rootPath: '/home/user/project', - watchId: firstWatchId, - message: 'bounded recovery failed' - }) - expect(firstTerminal).toHaveBeenCalledWith( - expect.objectContaining({ message: 'bounded recovery failed' }) - ) - expect(secondTerminal).toHaveBeenCalledTimes(1) - - const replacementTerminal = vi.fn() - await provider.watch('/home/user/project', vi.fn(), { - onTerminalError: replacementTerminal - }) - const replacementWatchId = mux.request.mock.calls[1][1].watchId as number - expect(replacementWatchId).not.toBe(firstWatchId) - - notifHandler('fs.watchFailed', { - rootPath: '/home/user/project', - watchId: firstWatchId, - message: 'late stale failure' - }) - expect(replacementTerminal).not.toHaveBeenCalled() - }) - - it('fans out same-root watch events and unwatches only after the last subscriber', async () => { - const first = vi.fn() - const second = vi.fn() - const unsubFirst = await provider.watch('/home/user/project', first) - const unsubSecond = await provider.watch('/home/user/project', second) - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith( - 'fs.watch', - { rootPath: '/home/user/project', watchId: expect.any(Number) }, - { signal: expect.any(AbortSignal) } - ) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(first).toHaveBeenCalledWith(events) - expect(second).toHaveBeenCalledWith(events) - - unsubFirst() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - notifHandler('fs.changed', { events }) - expect(second).toHaveBeenCalledTimes(2) - - unsubSecond() - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - }) - - it('shares equivalent Windows spellings under one provider watch generation', async () => { - const first = vi.fn() - const second = vi.fn() - const firstTerminal = vi.fn() - const secondTerminal = vi.fn() - const unsubFirst = await provider.watch('C:\\Repo', first, { - onTerminalError: firstTerminal - }) - const unsubSecond = await provider.watch('c:/repo/', second, { - onTerminalError: secondTerminal - }) - - expect(mux.request).toHaveBeenCalledTimes(1) - const watchId = mux.request.mock.calls[0][1].watchId as number - const notification = mux.onNotification.mock.calls[0][0] - notification('fs.changed', { - events: [{ kind: 'update', absolutePath: 'c:/repo/file.ts' }] - }) - expect(first).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalledTimes(1) - - unsubFirst() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: 'C:\\Repo' }) - notification('fs.watchFailed', { - rootPath: 'c:/repo', - watchId, - message: 'bounded recovery failed' - }) - expect(firstTerminal).not.toHaveBeenCalled() - expect(secondTerminal).toHaveBeenCalledTimes(1) - - unsubSecond() - }) - - it('shares an in-flight same-root watch setup across concurrent subscribers', async () => { - let resolveWatch: () => void = () => {} - mux.request.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveWatch = resolve - }) - ) - const first = vi.fn() - const second = vi.fn() - - const firstWatch = provider.watch('/home/user/project', first) - const secondWatch = provider.watch('/home/user/project', second) - - expect(mux.request).toHaveBeenCalledTimes(1) - resolveWatch() - const [unsubFirst, unsubSecond] = await Promise.all([firstWatch, secondWatch]) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(first).toHaveBeenCalledWith(events) - expect(second).toHaveBeenCalledWith(events) - - unsubFirst() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - unsubSecond() - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - }) - - it('does not retain a watch listener when fs.watch setup fails', async () => { - mux.request.mockRejectedValueOnce(new Error('watch unavailable')) - const first = vi.fn() - await expect(provider.watch('/home/user/project', first)).rejects.toThrow('watch unavailable') - - const second = vi.fn() - await provider.watch('/home/user/project', second) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(first).not.toHaveBeenCalled() - expect(second).toHaveBeenCalledWith(events) - }) - - it('does not forward sibling paths with matching prefixes', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [ - { kind: 'update', absolutePath: '/home/user/project-old/file.ts' }, - { kind: 'update', absolutePath: '/home/user/project2/file.ts' } - ] - }) - - expect(callback).not.toHaveBeenCalled() - }) - - it('matches Windows and UNC watch roots case-insensitively', async () => { - const driveCallback = vi.fn() - const uncCallback = vi.fn() - await provider.watch('C:\\Repo', driveCallback) - await provider.watch('//Server/Share/Repo', uncCallback) - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [ - { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' }, - { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } - ] - }) - - expect(driveCallback).toHaveBeenCalledWith([ - { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' } - ]) - expect(uncCallback).toHaveBeenCalledWith([ - { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } - ]) - }) - - it('sends fs.unwatch when last listener unsubscribes', async () => { - const callback = vi.fn() - const unsub = await provider.watch('/home/user/project', callback) - unsub() - - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - }) - - it('awaits acknowledged watcher teardown for destructive cleanup', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - mux.request.mockClear() - - await provider.closeWatch('/home/user/project') - - expect(mux.request).toHaveBeenCalledWith('fs.unwatchAndWait', { - rootPath: '/home/user/project' - }) - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - }) - expect(callback).not.toHaveBeenCalled() - }) - - it('fails destructive teardown closed on an older relay', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - mux.request.mockRejectedValueOnce( - Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - - await expect(provider.closeWatch('/home/user/project')).rejects.toThrow( - 'Reconnect the SSH target' - ) - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { - rootPath: '/home/user/project' - }) - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update' as const, absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(callback).toHaveBeenCalledWith(events) - }) - - it('sends fs.unwatch for active roots when disposed', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - - provider.dispose() - - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - }) - expect(callback).not.toHaveBeenCalled() - }) - - it('unwatches when disposed while fs.watch setup is still resolving', async () => { - let resolveWatch: () => void = () => {} - mux.request.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveWatch = resolve - }) - ) - const callback = vi.fn() - const pendingWatch = provider.watch('/home/user/project', callback) - - provider.dispose() - resolveWatch() - - await expect(pendingWatch).rejects.toThrow('SSH filesystem provider disposed') - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - }) - expect(callback).not.toHaveBeenCalled() - }) - - it('does not send fs.unwatch while other roots are watched', async () => { - const cb1 = vi.fn() - const cb2 = vi.fn() - const unsub1 = await provider.watch('/home/user/project-a', cb1) - await provider.watch('/home/user/project-b', cb2) - - unsub1() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { - rootPath: '/home/user/project-b' - }) - }) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts deleted file mode 100644 index 18c185402f12..000000000000 --- a/src/main/providers/ssh-filesystem-provider.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { readSshDirectoryWithSftpFallback } from './ssh-directory-listing' -import { readSshMarkdownDocuments } from './ssh-markdown-document-listing' -import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' -import type { PathExistenceResult } from '../../shared/path-existence-batch' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' -import { uploadBuffer } from '../ssh/sftp-upload' -import { listSshFiles } from './ssh-file-listing' -import { lstatViaSftp } from './ssh-filesystem-provider-sftp' -import { - downloadFileViaSftp, - downloadFolderViaSftp, - type SftpFactory -} from './ssh-filesystem-download' -import { openSshFileUploadSession, type SshRawTransferOptions } from './ssh-filesystem-file-upload' -import { - closeSshFilesystemWatch, - registerSshFilesystemWatch, - stopSshFilesystemWatchRegistration, - type WatchRegistration -} from './ssh-filesystem-provider-watch' -import type { - IFilesystemProvider, - FileRangeReadResult, - FileReadLimits, - FileStat, - FileReadResult, - FileUploadSession, - TerminalArtifactAccessOptions -} from './types' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-types' -import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' -import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { - probeSshQuickOpenSearchCapability, - probeSshRangedReadCapability -} from './ssh-filesystem-provider-capabilities' -import { readSshFileRange } from './ssh-filesystem-range-read' -import { - readSshTerminalArtifact, - writeSshTerminalArtifact -} from './ssh-filesystem-terminal-artifact' -import { readSshDocPreviewFile } from './ssh-filesystem-doc-preview' -const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 -export class SshFilesystemProvider implements IFilesystemProvider { - private connectionId: string - private mux: SshChannelMultiplexer - private watchListeners = new Map() - private unsubscribeNotifications: (() => void) | null = null - private tempDirPromise: Promise | null = null - private disposed = false - private loggedStreamFallback = false - readonly downloadFolder?: IFilesystemProvider['downloadFolder'] - - constructor( - connectionId: string, - mux: SshChannelMultiplexer, - private readonly createSftp?: SftpFactory, - private readonly rawTransfer?: SshRawTransferOptions, - hostPlatform?: RemoteHostPlatform - ) { - this.connectionId = connectionId - this.mux = mux - - if (createSftp) { - // Why: system SSH has raw single-file transfer but no ssh2 SFTP channel; - // omitting this method makes folder capability truthful at the provider boundary. - // windowsRemotePaths is provider-owned (from host platform), not a caller option. - const windowsRemotePaths = hostPlatform ? isWindowsRemoteHost(hostPlatform) : undefined - this.downloadFolder = (sourcePath, destinationPath, options) => - downloadFolderViaSftp(createSftp, sourcePath, destinationPath, { - ...options, - windowsRemotePaths - }) - } - - this.unsubscribeNotifications = mux.onNotification((method, params) => - routeSshFilesystemWatchNotification(this.watchListeners, method, params) - ) - } - - dispose(): void { - if (this.disposed) { - return - } - this.disposed = true - if (this.unsubscribeNotifications) { - this.unsubscribeNotifications() - this.unsubscribeNotifications = null - } - for (const registration of this.watchListeners.values()) { - stopSshFilesystemWatchRegistration(this.mux, registration) - } - this.watchListeners.clear() - } - - getConnectionId(): string { - return this.connectionId - } - - async readDir(dirPath: string, options?: { followSymlinks?: boolean }): Promise { - return readSshDirectoryWithSftpFallback(this.mux, dirPath, this.createSftp, options) - } - - async readFile(filePath: string, limits?: FileReadLimits): Promise { - // Why: streaming is the default path so previews above the legacy single- - // frame budget (~12 MB after base64) don't hit MAX_MESSAGE_SIZE. Old relays - // that don't implement fs.readFileStream surface as MethodNotFound; we fall - // back to the legacy single-shot fs.readFile (which retains the old 10 MB - // cap on those hosts). - try { - return await readFileViaStream(this.mux, filePath, limits) - } catch (err) { - if (isMethodNotFoundError(err)) { - if (!this.loggedStreamFallback) { - this.loggedStreamFallback = true - console.warn( - '[ssh-fs] Relay does not implement fs.readFileStream; falling back to fs.readFile (10 MB cap)' - ) - } - return (await this.mux.request('fs.readFile', { filePath })) as FileReadResult - } - throw err - } - } - - readDocPreviewFile( - request: Parameters>[0] - ): ReturnType> { - return readSshDocPreviewFile(this.mux, request) - } - - readFileRange( - filePath: string, - position: number, - length: number, - options?: { signal?: AbortSignal } - ): Promise { - return readSshFileRange(this.mux, filePath, position, length, options?.signal) - } - - supportsFileRangeRead(options?: { signal?: AbortSignal }): Promise { - return probeSshRangedReadCapability(this.mux, options?.signal) - } - - readTerminalArtifact( - filePath: string, - options: TerminalArtifactAccessOptions - ): Promise { - return readSshTerminalArtifact(this.mux, filePath, options) - } - - writeTerminalArtifact( - filePath: string, - content: string, - options: TerminalArtifactAccessOptions - ): Promise { - return writeSshTerminalArtifact(this.mux, filePath, content, options) - } - - async downloadFile(sourcePath: string, destinationPath: string): Promise { - // Why: system SSH targets cannot open an ssh2-owned SFTP channel. - if (this.rawTransfer?.downloadFile) { - await this.rawTransfer.downloadFile(sourcePath, destinationPath) - return - } - await downloadFileViaSftp(this.createSftp, sourcePath, destinationPath) - } - - async openFileUploadSession(): Promise { - return openSshFileUploadSession(this.createSftp, this.rawTransfer) - } - - async getTempDir(): Promise { - this.tempDirPromise ??= this.mux.request('fs.tempDir', {}).then( - (result) => result as string, - (err) => { - this.tempDirPromise = null - if (isMethodNotFoundError(err)) { - return '/tmp' - } - throw err - } - ) - return this.tempDirPromise - } - - async writeFile(filePath: string, content: string): Promise { - await this.mux.request('fs.writeFile', { filePath, content }) - } - - async writeFileBase64(filePath: string, contentBase64: string): Promise { - await this.writeFileBase64Chunk(filePath, contentBase64, false) - } - - async writeFileBase64Chunk( - filePath: string, - contentBase64: string, - append: boolean - ): Promise { - const contents = Buffer.from(contentBase64, 'base64') - if (this.rawTransfer?.writeBuffer) { - await this.rawTransfer.writeBuffer(filePath, contents, { append, exclusive: !append }) - return - } - if (!this.createSftp) { - throw new Error('remote_binary_upload_unavailable') - } - const sftp = await this.createSftp() - try { - // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes - // directly so runtime uploads do not corrupt images, PDFs, or archives. - await uploadBuffer(sftp, contents, filePath, { - append, - exclusive: !append - }) - } finally { - sftp.end() - } - } - - pathsExist(filePaths: string[]): Promise { - return readSshPathExistenceBatch(this.mux, filePaths, (path) => this.stat(path)) - } - - async stat(filePath: string): Promise { - return (await this.mux.request('fs.stat', { filePath })) as FileStat - } - - async lstat(filePath: string): Promise { - try { - return (await this.mux.request('fs.lstat', { filePath })) as FileStat - } catch (err) { - if (!isMethodNotFoundError(err)) { - throw err - } - if (!this.createSftp) { - throw new Error('remote_lstat_unavailable') - } - const sftp = await this.createSftp() - try { - // Why: older relays predate fs.lstat, but SFTP can still preserve - // symlink identity for orphaned-worktree safety checks. - return await lstatViaSftp(sftp, filePath) - } finally { - sftp.end() - } - } - } - - async scanWorkspaceSpace( - rootPath: string, - options?: { signal?: AbortSignal } - ): Promise { - return (await this.mux.request( - 'fs.workspaceSpaceScan', - { rootPath }, - { signal: options?.signal, timeoutMs: WORKSPACE_SPACE_SCAN_TIMEOUT_MS } - )) as WorkspaceSpaceDirectoryScanResult - } - - async deletePath(targetPath: string, recursive?: boolean): Promise { - await this.mux.request('fs.deletePath', { targetPath, recursive }) - } - - async createFile(filePath: string): Promise { - await this.mux.request('fs.createFile', { filePath }) - } - - async createDir(dirPath: string): Promise { - await this.mux.request('fs.createDir', { dirPath }) - } - - async createDirNoClobber(dirPath: string): Promise { - await this.mux.request('fs.createDirNoClobber', { dirPath }) - } - - async rename(oldPath: string, newPath: string): Promise { - await this.mux.request('fs.rename', { oldPath, newPath }) - } - - async renameNoClobber(oldPath: string, newPath: string): Promise { - try { - await this.mux.request('fs.renameNoClobber', { oldPath, newPath }) - } catch (err) { - if (isMethodNotFoundError(err)) { - // Why: falling back to raw fs.rename can silently clobber the target on - // older relays. Fail closed and let reconnect deploy the safe relay. - throw new Error('Remote safe rename is unavailable. Reconnect the SSH target and retry.') - } - throw err - } - } - - async copy(source: string, destination: string): Promise { - await this.mux.request('fs.copy', { source, destination }) - } - - async realpath(filePath: string): Promise { - return (await this.mux.request('fs.realpath', { filePath })) as string - } - - async search(opts: SearchOptions, options?: { signal?: AbortSignal }): Promise { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fs.search returns the relay's SearchResult contract; signal stays in local transport options. - return (await this.mux.request('fs.search', opts, { signal: options?.signal })) as SearchResult - } - - async listFiles( - rootPath: string, - options?: Parameters[1] - ): Promise { - return listSshFiles(this.mux, rootPath, options) - } - - listMarkdownDocuments = (rootPath: string, options?: { signal?: AbortSignal }) => - readSshMarkdownDocuments(this.mux, rootPath, options?.signal, () => - this.listFiles(rootPath, { signal: options?.signal }) - ) - - supportsQuickOpenSearch = ( - options: { signal?: AbortSignal; minimumVersion?: number } = {} - ): Promise => - probeSshQuickOpenSearchCapability(this.mux, options.signal, options.minimumVersion) - async watch( - rootPath: string, - callback: (events: FsChangeEvent[]) => void, - options?: { signal?: AbortSignal; onTerminalError?: (error: Error) => void } - ): Promise<() => void> { - return registerSshFilesystemWatch({ - mux: this.mux, - disposed: () => this.disposed, - registrations: this.watchListeners, - rootPath, - callback, - onTerminalError: options?.onTerminalError, - signal: options?.signal - }) - } - - async closeWatch(rootPath: string): Promise { - await closeSshFilesystemWatch(this.mux, this.watchListeners, rootPath) - } -} diff --git a/src/main/providers/ssh-filesystem-range-read.ts b/src/main/providers/ssh-filesystem-range-read.ts deleted file mode 100644 index 77ec0872363f..000000000000 --- a/src/main/providers/ssh-filesystem-range-read.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { validateFileRangeRequest } from '../../shared/file-range-read' -import { - FileRangeReadUnsupportedError, - type FileRangeReadResult -} from './filesystem-provider-contract' - -export async function readSshFileRange( - mux: SshChannelMultiplexer, - filePath: string, - rawPosition: number, - rawLength: number, - signal?: AbortSignal -): Promise { - // Same validator the host runs, so an out-of-contract request fails locally - // as a typed error instead of costing a round trip and coming back as an - // opaque -32000. - const { position, length } = validateFileRangeRequest(rawPosition, rawLength) - let result: unknown - try { - // No timeoutMs: the multiplexer's 30s default is right for a bounded - // MAX_FILE_RANGE_READ_BYTES window on a slow link. The capability probe - // pins a shorter one because it gates a UI decision, not a data read. - result = await mux.request( - 'fs.readFileRange', - { filePath, position, length }, - signal ? { signal } : undefined - ) - } catch (err) { - // Why throw rather than read the whole file here: a tailing caller issues - // several reads per snapshot, so a per-call fallback is quadratic on a - // growing transcript. Callers probe once and snapshot instead. - if (isMethodNotFoundError(err)) { - throw new FileRangeReadUnsupportedError() - } - throw err - } - const payload = result as { base64?: unknown; bytesRead?: unknown } | null - if (typeof payload?.base64 !== 'string' || typeof payload.bytesRead !== 'number') { - throw new Error('fs.readFileRange returned a malformed response') - } - const bytes = Buffer.from(payload.base64, 'base64') - // The remote is untrusted for framing: a count that disagrees with the - // payload would silently shift every downstream offset. - if ( - !Number.isSafeInteger(payload.bytesRead) || - payload.bytesRead < 0 || - payload.bytesRead > length || - payload.bytesRead !== bytes.length - ) { - throw new Error('fs.readFileRange returned an inconsistent byte count') - } - return { bytes, bytesRead: payload.bytesRead } -} diff --git a/src/main/providers/ssh-filesystem-terminal-artifact.ts b/src/main/providers/ssh-filesystem-terminal-artifact.ts deleted file mode 100644 index f4088df46a91..000000000000 --- a/src/main/providers/ssh-filesystem-terminal-artifact.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import type { FileReadResult, FileStat, TerminalArtifactAccessOptions } from './types' - -export async function readSshTerminalArtifact( - mux: SshChannelMultiplexer, - filePath: string, - options: TerminalArtifactAccessOptions -): Promise { - try { - return (await mux.request('fs.readTerminalArtifact', { - filePath, - expectedRealPath: options.expectedRealPath, - expectedStatIdentity: options.expectedStatIdentity, - maxBytes: options.maxBytes - })) as FileReadResult - } catch (err) { - if (isMethodNotFoundError(err)) { - throw new Error( - 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' - ) - } - throw err - } -} - -export async function writeSshTerminalArtifact( - mux: SshChannelMultiplexer, - filePath: string, - content: string, - options: TerminalArtifactAccessOptions -): Promise { - let result: { stat?: FileStat } - try { - result = (await mux.request('fs.writeTerminalArtifact', { - filePath, - content, - expectedRealPath: options.expectedRealPath, - expectedStatIdentity: options.expectedStatIdentity, - maxBytes: options.maxBytes - })) as { stat?: FileStat } - } catch (err) { - if (isMethodNotFoundError(err)) { - throw new Error( - 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' - ) - } - throw err - } - if (!result.stat) { - throw new Error('terminal_file_grant_stale') - } - return result.stat -} diff --git a/src/main/providers/ssh-filesystem-watch-notifications.test.ts b/src/main/providers/ssh-filesystem-watch-notifications.test.ts deleted file mode 100644 index d50b32bc8265..000000000000 --- a/src/main/providers/ssh-filesystem-watch-notifications.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' -import type { WatchRegistration } from './ssh-filesystem-provider-watch' - -function registration(rootPath: string, ...callbacks: ((events: FsChangeEvent[]) => void)[]) { - return { - rootPath, - callbacks: new Set(callbacks), - terminalCallbacks: new Map(), - remoteWatchId: 1, - ready: true, - stopping: false, - unwatchSent: false - } as unknown as WatchRegistration -} - -function changed(...paths: string[]): FsChangeEvent[] { - return paths.map((absolutePath) => ({ kind: 'update', absolutePath }) as FsChangeEvent) -} - -function route(registrations: Map, events: FsChangeEvent[]): void { - routeSshFilesystemWatchNotification(registrations, 'fs.changed', { events }) -} - -// Reference: the pre-change routing, which called isPathInsideOrEqual per pair and -// so re-normalized the candidate for every root. Not circular for what this asserts -// -- the change under test is that one shared normalization of each candidate still -// matches every root the per-pair normalization did. -function referenceRoute(roots: string[], events: FsChangeEvent[]): Record { - const result: Record = {} - for (const rootPath of roots) { - const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath)) - if (matching.length > 0) { - result[rootPath] = matching.map((event) => event.absolutePath) - } - } - return result -} - -describe('routeSshFilesystemWatchNotification fs.changed fan-out', () => { - it('delivers only the events inside each root', () => { - const alpha = vi.fn() - const beta = vi.fn() - const registrations = new Map([ - ['/repo/alpha', registration('/repo/alpha', alpha)], - ['/repo/beta', registration('/repo/beta', beta)] - ]) - - route( - registrations, - changed( - '/repo/alpha/src/a.ts', - '/repo/beta/src/b.ts', - '/repo/alpha/src/c.ts', - '/elsewhere/d.ts' - ) - ) - - expect(alpha).toHaveBeenCalledTimes(1) - expect(alpha.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ - '/repo/alpha/src/a.ts', - '/repo/alpha/src/c.ts' - ]) - expect(beta.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ - '/repo/beta/src/b.ts' - ]) - }) - - // Why: the fix hoists normalization out of the inner loop, so the risk is that a - // shared pre-normalized candidate stops matching a root it used to match. - it('routes identically to per-pair normalization', () => { - const roots = ['/repo/alpha', '/repo/alpha-extra', '/repo/beta/', '/repo'] - const events = changed( - '/repo/alpha/src/a.ts', - '/repo/alpha-extra/src/b.ts', - '/repo/beta/src/c.ts', - '/repo/alpha', - '/repo//alpha//src//d.ts', - '/repo/gamma/e.ts', - '/elsewhere/f.ts' - ) - const seen: Record = {} - const registrations = new Map( - roots.map((rootPath) => [ - rootPath, - registration(rootPath, (delivered) => { - seen[rootPath] = delivered.map((event) => event.absolutePath) - }) - ]) - ) - - route(registrations, events) - - expect(seen).toEqual(referenceRoute(roots, events)) - }) - - // Why: normalizeRuntimePathForComparison is not idempotent for WSL UNC paths, so - // a candidate normalized once up front must still match a root normalized once. - it('matches WSL UNC roots whose case survives only a single fold', () => { - const received = vi.fn() - const registrations = new Map([ - ['wsl', registration('\\\\wsl.localhost\\Ubuntu\\home\\User\\Repo', received)] - ]) - - route(registrations, changed('//wsl$/UBUNTU/home/User/Repo/src/a.ts')) - - expect(received).toHaveBeenCalledTimes(1) - expect(received.mock.calls[0][0][0].absolutePath).toBe('//wsl$/UBUNTU/home/User/Repo/src/a.ts') - }) - - // Why: macOS emits NFD names while stored roots are often NFC; both spell the - // same directory, and the shared candidate must still fold into the root. - it('matches a root recorded in NFC against NFD event paths', () => { - const received = vi.fn() - const registrations = new Map([['nfc', registration('/repo/café'.normalize('NFC'), received)]]) - - route(registrations, changed('/repo/café/src/a.ts'.normalize('NFD'))) - - expect(received).toHaveBeenCalledTimes(1) - }) - - it('does not treat a sibling with a shared prefix as inside the root', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - route(registrations, changed('/repo/alphabet/src/a.ts')) - - expect(received).not.toHaveBeenCalled() - }) - - it('delivers the root itself to a watcher on that root', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - route(registrations, changed('/repo/alpha')) - - expect(received).toHaveBeenCalledTimes(1) - }) - - it('notifies every callback registered on a shared root', () => { - const first = vi.fn() - const second = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', first, second)]]) - - route(registrations, changed('/repo/alpha/src/a.ts')) - - expect(first).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalledTimes(1) - }) - - it('skips callbacks entirely when no event is inside the root', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - route(registrations, changed('/elsewhere/a.ts', '/repo/beta/b.ts')) - - expect(received).not.toHaveBeenCalled() - }) - - it('ignores methods other than fs.changed and fs.watchFailed', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - routeSshFilesystemWatchNotification(registrations, 'pty.data', { - events: changed('/repo/alpha/src/a.ts') - }) - - expect(received).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/ssh-filesystem-watch-notifications.ts b/src/main/providers/ssh-filesystem-watch-notifications.ts deleted file mode 100644 index aed30f16473f..000000000000 --- a/src/main/providers/ssh-filesystem-watch-notifications.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { - createNormalizedPathInsideOrEqualMatcher, - normalizeRuntimePathForComparison -} from '../../shared/cross-platform-path' -import { - failSshFilesystemWatchRegistration, - type WatchRegistration -} from './ssh-filesystem-provider-watch' - -export function routeSshFilesystemWatchNotification( - registrations: Map, - method: string, - params: Record -): void { - if (method === 'fs.changed') { - const events = params.events as FsChangeEvent[] - // Why normalize once: isPathInsideOrEqual NFC-normalizes both sides, so the - // nested fan-out re-normalized every event path once per watch root. - const normalizedEvents = events.map((event) => ({ - event, - normalizedPath: normalizeRuntimePathForComparison(event.absolutePath) - })) - for (const registration of registrations.values()) { - const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(registration.rootPath) - const matching = normalizedEvents - .filter(({ normalizedPath }) => isInsideRoot(normalizedPath)) - .map(({ event }) => event) - if (matching.length > 0) { - for (const callback of registration.callbacks) { - callback(matching) - } - } - } - return - } - if (method !== 'fs.watchFailed') { - return - } - const rootPath = typeof params.rootPath === 'string' ? params.rootPath : null - const watchId = typeof params.watchId === 'number' ? params.watchId : null - if (rootPath && watchId !== null) { - const message = - typeof params.message === 'string' ? params.message : 'remote file watcher failed' - failSshFilesystemWatchRegistration(registrations, rootPath, watchId, new Error(message)) - } -} diff --git a/src/main/providers/ssh-listing-compatibility.test.ts b/src/main/providers/ssh-listing-compatibility.test.ts deleted file mode 100644 index 903834aa89ec..000000000000 --- a/src/main/providers/ssh-listing-compatibility.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { listSshFiles } from './ssh-file-listing' -import { describe, expect, it, vi } from 'vitest' -import { readSshMarkdownDocuments } from './ssh-markdown-document-listing' -import { readSshDirectoryBounded } from './ssh-directory-listing' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' - -function muxFixture(result: unknown, error?: Error) { - const mock = { - request: error ? vi.fn().mockRejectedValue(error) : vi.fn().mockResolvedValue(result), - notify: vi.fn(), - onNotification: vi.fn(() => () => {}), - onNotificationByMethod: vi.fn(() => () => {}), - onDispose: vi.fn(() => () => {}), - isDisposed: () => false - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The reader uses only these request, notification, and disposal operations. - return { mock, mux: mock as unknown as SshChannelMultiplexer } -} -const unsupported = () => Object.assign(new Error('Method not found'), { code: -32601 }) - -describe('SSH listing compatibility', () => { - it('accepts late full-inventory files in old plain replies without a count cap', async () => { - const paths = Array.from({ length: 25002 }, (_, i) => `src/file-${i}.ts`) - const { mux, mock } = muxFixture(paths) - expect((await listSshFiles(mux, '/repo')).at(-1)).toBe('src/file-25001.ts') - expect(mock.request.mock.calls[0][1].maxResults).toBeUndefined() - const limited = muxFixture(paths.slice(0, 3)) - expect(await listSshFiles(limited.mux, '/repo', { maxResults: 3 })).toHaveLength(3) - }) - - it('keeps complete small old-peer Markdown inventories useful', async () => { - const { mux } = muxFixture(undefined, unsupported()) - const loadLegacy = vi.fn().mockResolvedValue(['source.ts', 'docs/README.md']) - const result = await readSshMarkdownDocuments(mux, '/repo', undefined, loadLegacy) - expect(result.map((document) => document.relativePath)).toEqual(['docs/README.md']) - expect(loadLegacy).toHaveBeenCalledTimes(1) - }) - - it('keeps late Markdown files in large old-peer source inventories', async () => { - const paths = Array.from({ length: 25_002 }, (_, index) => `src/file-${index}.ts`) - paths.push('docs/late.md') - const { mux, mock } = muxFixture(paths) - mock.request.mockRejectedValueOnce(unsupported()) - const provider = new SshFilesystemProvider('legacy', mux) - await expect(provider.listMarkdownDocuments('/repo')).resolves.toEqual([ - { - filePath: '/repo/docs/late.md', - relativePath: 'docs/late.md', - basename: 'late.md', - name: 'late' - } - ]) - expect(mock.request).toHaveBeenLastCalledWith('fs.listFiles', { - rootPath: '/repo', - __streamResponse: true - }) - provider.dispose() - }) - - it('still rejects an old-peer inventory with too many Markdown documents', async () => { - const { mux } = muxFixture(undefined, unsupported()) - await expect( - readSshMarkdownDocuments(mux, '/repo', undefined, async () => Array(20_001).fill('source.md')) - ).rejects.toThrow('Workspace is too large') - }) - - it('uses bounded SFTP fallback for old directory peers and preserves failures', async () => { - const { mux } = muxFixture(undefined, unsupported()) - const fallback = vi - .fn() - .mockResolvedValue([{ name: 'folder', isDirectory: true, isSymlink: false }]) - expect(await readSshDirectoryBounded(mux, '/repo', fallback)).toHaveLength(1) - const failure = muxFixture(undefined, new Error('Permission denied')) - await expect(readSshDirectoryBounded(failure.mux, '/repo', fallback)).rejects.toThrow( - 'Permission denied' - ) - expect(fallback).toHaveBeenCalledTimes(1) - }) - - it('validates new-peer directory and Markdown metadata before exposing it', async () => { - const directory = muxFixture([ - { name: 'x'.repeat(5 * 1024 * 1024), isDirectory: false, isSymlink: false } - ]) - await expect(readSshDirectoryBounded(directory.mux, '/repo')).rejects.toThrow() - const markdown = muxFixture( - Array.from({ length: 20_001 }, () => ({ - filePath: '/repo/a.md', - relativePath: 'a.md', - basename: 'a.md', - name: 'a' - })) - ) - await expect(readSshMarkdownDocuments(markdown.mux, '/repo')).rejects.toThrow( - 'Workspace is too large' - ) - }) -}) diff --git a/src/main/providers/ssh-markdown-document-listing.ts b/src/main/providers/ssh-markdown-document-listing.ts deleted file mode 100644 index d3b6be57b8b8..000000000000 --- a/src/main/providers/ssh-markdown-document-listing.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { markdownDocumentsFromRelativePaths } from '../../shared/markdown-document-paths' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import type { MarkdownDocument } from '../../shared/filesystem-entry-types' -import { assertMarkdownDocumentsWithinLimit } from '../../shared/markdown-document-listing-limits' - -export async function readSshMarkdownDocuments( - mux: SshChannelMultiplexer, - rootPath: string, - signal?: AbortSignal, - loadLegacy?: () => Promise -): Promise { - let result: unknown - try { - result = await requestGitStreamable( - mux, - 'fs.listMarkdownDocuments', - { rootPath }, - { signal, maxResponseBytes: 16 * 1024 * 1024 } - ) - } catch (error) { - if (isMethodNotFoundError(error)) { - if (loadLegacy) { - return markdownDocumentsFromRelativePaths(rootPath, await loadLegacy()) - } - throw new Error('Markdown discovery requires an updated SSH relay. Reconnect and retry.') - } - throw error - } - assertMarkdownDocumentsWithinLimit(result) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared validator checked every document field and the aggregate budget. - return result as MarkdownDocument[] -} diff --git a/src/main/providers/ssh-plain-shell-pty-provider.test.ts b/src/main/providers/ssh-plain-shell-pty-provider.test.ts deleted file mode 100644 index 4b223eb5578e..000000000000 --- a/src/main/providers/ssh-plain-shell-pty-provider.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' -import type { ClientChannel, PseudoTtyOptions } from 'ssh2' -import { SshPlainShellPtyProvider } from './ssh-plain-shell-pty-provider' - -class FakeShellChannel extends EventEmitter { - readonly stderr = new EventEmitter() - readonly writes: string[] = [] - readonly setWindow = vi.fn() - readonly signal = vi.fn() - readonly close = vi.fn() - write(data: string): boolean { - this.writes.push(data) - return true - } -} - -const MODE = { reason: 'no_runtime', message: 'No runtime here.' } - -function createProvider(options: { posixHost?: boolean } = {}) { - const channels: FakeShellChannel[] = [] - const opened: PseudoTtyOptions[] = [] - const provider = new SshPlainShellPtyProvider( - 'target-1', - async (pty) => { - opened.push(pty) - const channel = new FakeShellChannel() - channels.push(channel) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fake implements every ClientChannel member the provider touches. - return channel as unknown as ClientChannel - }, - MODE, - options.posixHost ?? true, - 7 - ) - const data: { id: string; data: string; providerGeneration: number }[] = [] - const exits: { id: string; code: number }[] = [] - provider.onData((payload) => data.push(payload)) - provider.onExit((payload) => exits.push(payload)) - return { provider, channels, opened, data, exits } -} - -describe('SshPlainShellPtyProvider', () => { - it('opens a pty-req shell at the requested size and shows the plain SSH reason', async () => { - const { provider, channels, opened, data } = createProvider() - const result = await provider.spawn({ cols: 120, rows: 40, cwd: "/srv/it's" }) - - expect(opened).toEqual([{ cols: 120, rows: 40, term: 'xterm-256color' }]) - expect(result.id).toMatch(/^ssh:target-1@@plain-/) - expect(result.incarnationId).toEqual(expect.any(String)) - expect(result.sessionExpired).toBeUndefined() - expect(data[0]).toMatchObject({ id: result.id, providerGeneration: 7 }) - expect(data[0]?.data).toContain('No runtime here.') - expect(channels[0]?.writes).toEqual([` cd -- '/srv/it'\\''s'\n`]) - }) - - it('keeps a home-relative cwd expandable by leaving the tilde unquoted', async () => { - const { provider, channels } = createProvider() - await provider.spawn({ cols: 80, rows: 24, cwd: "~/it's" }) - await provider.spawn({ cols: 80, rows: 24, cwd: '~' }) - expect(channels[0]?.writes).toEqual([` cd -- ~/'it'\\''s'\n`]) - expect(channels[1]?.writes).toEqual([' cd -- ~\n']) - }) - - it('does not type a POSIX cd into a Windows host shell', async () => { - const { provider, channels } = createProvider({ posixHost: false }) - await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\work' }) - expect(channels[0]?.writes).toEqual([]) - }) - - it('forwards output, input and resize over the channel', async () => { - const { provider, channels, data } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - const channel = channels[0]! - - // A multi-byte character split across chunks must not decode as replacement characters. - const euro = Buffer.from('€', 'utf8') - channel.emit('data', euro.subarray(0, 1)) - channel.emit('data', euro.subarray(1)) - channel.stderr.emit('data', Buffer.from('err')) - expect(data.slice(1).map((d) => d.data)).toEqual(['€', 'err']) - - expect(provider.write(id, 'ls\r')).toBe(true) - expect(provider.writeWithSettlement(id, 'x')).toEqual({ outcome: 'accepted' }) - expect(channel.writes).toEqual(['ls\r', 'x']) - - provider.resize(id, 100, 30) - expect(channel.setWindow).toHaveBeenCalledWith(30, 100, 0, 0) - await expect(provider.getAppliedSize(id)).resolves.toEqual({ cols: 100, rows: 30 }) - }) - - it('reports exited only when the host sent an exit status', async () => { - const { provider, channels, exits } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - channels[0]!.emit('exit', 3) - channels[0]!.emit('close') - - expect(exits).toEqual([expect.objectContaining({ id, code: 3, providerGeneration: 7 })]) - await expect(provider.probePtyLiveness(id)).resolves.toBe(false) - }) - - it('treats a close without an exit status as unverifiable, not exited', async () => { - const { provider, channels, exits, data } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - channels[0]!.emit('close') - - expect(exits).toEqual([]) - expect(data.at(-1)?.data).toContain('unverifiable') - await expect(provider.probePtyLiveness(id)).resolves.toBeNull() - expect(provider.writeWithSettlement(id, 'x')).toEqual({ - outcome: 'refused', - reason: 'provider_unavailable' - }) - }) - - it('reports an operator close as an exit once the channel closes', async () => { - const { provider, channels, exits } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id) - expect(channels[0]!.close).toHaveBeenCalled() - channels[0]!.emit('close') - expect(exits).toEqual([expect.objectContaining({ id, code: 0 })]) - }) - - it('leaves open shells unverifiable when the transport is disposed', async () => { - const { provider, channels, exits } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - provider.dispose() - channels[0]!.emit('close') - - expect(exits).toEqual([]) - await expect(provider.probePtyLiveness(id)).resolves.toBeNull() - await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('not active') - }) - - it('leaves a PTY it never minted unverifiable, since an earlier relay may still run it', async () => { - const { provider } = createProvider() - await expect(provider.probePtyLiveness('ssh:target-1@@prior-relay-pty')).resolves.toBeNull() - }) - - it('starts a fresh shell for a reattach request and says the session expired', async () => { - const { provider } = createProvider() - const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:target-1@@pty-3' }) - expect(result.sessionExpired).toBe(true) - }) - - it('refuses relay-only launches with the plain SSH reason', async () => { - const { provider, opened } = createProvider() - await expect(provider.spawn({ cols: 80, rows: 24, launchAgent: 'claude' })).rejects.toThrow( - 'needs the Orca remote server' - ) - await expect(provider.spawn({ cols: 80, rows: 24, attachOnly: true })).rejects.toThrow( - '(no_runtime)' - ) - expect(opened).toEqual([]) - }) -}) diff --git a/src/main/providers/ssh-plain-shell-pty-provider.ts b/src/main/providers/ssh-plain-shell-pty-provider.ts deleted file mode 100644 index 2272bfda36bd..000000000000 --- a/src/main/providers/ssh-plain-shell-pty-provider.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * PTY provider for plain SSH mode (design D6 rung D): each terminal is one ssh2 session channel - * with pty-req + shell. Nothing persists on the host, so a lost channel is `unverifiable` and a - * reconnect starts a fresh shell. - */ -import { randomUUID } from 'node:crypto' -import { StringDecoder } from 'node:string_decoder' -import type { ClientChannel, PseudoTtyOptions } from 'ssh2' -import { WRITE_ACCEPTED, type WriteSettlement } from '../../shared/pty-write-settlement' -import { PlainSshUnsupportedError } from '../ssh/ssh-plain-ssh-mode' -import type { SshPlainSshMode } from '../../shared/ssh-types' -import { toAppSshPtyId } from './ssh-pty-id' -import type { - SshPtyDataCallback, - SshPtyExitCallback, - SshPtyReplayCallback -} from './ssh-pty-provider-contract' -import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' - -export type PlainShellOpener = (pty: PseudoTtyOptions) => Promise - -type PlainShell = { - channel: ClientChannel - incarnation: string - cwd: string - cols: number - rows: number - exitCode: number | null - closeRequested: boolean -} - -const TERM = 'xterm-256color' -// Why: sshd reports a signalled shell as exit-signal, not exit-status; mirror the shell's 128+n. -const SIGNALLED_EXIT_CODE = 129 - -function quotePosixPath(path: string): string { - return `'${path.replace(/'/g, `'\\''`)}'` -} - -// Why: a quoted `~` is literal, so the home prefix stays outside the quotes to expand. -function posixCdTarget(path: string): string { - if (path === '~') { - return '~' - } - return path.startsWith('~/') ? `~/${quotePosixPath(path.slice(2))}` : quotePosixPath(path) -} - -export function plainSshTerminalNotice(mode: SshPlainSshMode): string { - return `\x1b[2m[Orca] Plain SSH terminal: ${mode.message}\x1b[0m\r\n` -} - -const LOST_NOTICE = - '\r\n\x1b[2m[Orca] The SSH connection closed before the shell reported an exit; its state on ' + - 'the host is unverifiable. Plain SSH terminals are not restored on reconnect.\x1b[0m\r\n' - -export class SshPlainShellPtyProvider implements IPtyProvider { - private readonly shells = new Map() - // Why only confirmed exits answer absent: an id this provider never minted may still run on an earlier relay. - private readonly exited = new Set() - private readonly dataListeners = new Set() - private readonly exitListeners = new Set() - private disposed = false - - constructor( - private readonly connectionId: string, - private readonly openShell: PlainShellOpener, - private readonly mode: SshPlainSshMode, - private readonly posixHost: boolean, - readonly providerGeneration: number - ) {} - - async spawn(opts: PtySpawnOptions): Promise { - if (this.disposed) { - throw new Error('SSH connection is not active') - } - if (opts.agentSessionEnsure || opts.agentSessionCreateOperationId || opts.launchAgent) { - throw new PlainSshUnsupportedError('Launching agents', this.mode) - } - if (opts.attachOnly) { - throw new PlainSshUnsupportedError('Reattaching a terminal session', this.mode) - } - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - const cols = Math.max(1, opts.cols) - const rows = Math.max(1, opts.rows) - const channel = await this.openShell({ cols, rows, term: TERM }) - if (this.disposed) { - channel.close() - throw new Error('SSH connection is not active') - } - const id = toAppSshPtyId(this.connectionId, `plain-${randomUUID()}`) - const shell: PlainShell = { - channel, - incarnation: randomUUID(), - cwd: opts.cwd ?? '', - cols, - rows, - exitCode: null, - closeRequested: false - } - this.shells.set(id, shell) - this.wireChannel(id, shell) - this.emitData(id, shell, plainSshTerminalNotice(this.mode)) - if (opts.cwd && this.posixHost) { - // Why: a shell channel has no cwd request; a leading space keeps it out of most histories. - channel.write(` cd -- ${posixCdTarget(opts.cwd)}\n`) - } - if (opts.command && opts.commandDelivery === 'provider') { - channel.write(`${opts.command}\n`) - } - return { - id, - incarnationId: shell.incarnation, - ...(opts.sessionId ? { sessionExpired: true } : {}) - } - } - - private wireChannel(id: string, shell: PlainShell): void { - const stdout = new StringDecoder('utf8') - const stderr = new StringDecoder('utf8') - shell.channel.on('data', (chunk: Buffer) => this.emitData(id, shell, stdout.write(chunk))) - shell.channel.stderr?.on('data', (chunk: Buffer) => - this.emitData(id, shell, stderr.write(chunk)) - ) - shell.channel.on('exit', (code: number | null) => { - shell.exitCode = typeof code === 'number' ? code : SIGNALLED_EXIT_CODE - }) - // Why: channel errors surface as close; the close handler owns the verdict. - shell.channel.on('error', () => {}) - shell.channel.on('close', () => this.settleClose(id, shell)) - } - - private settleClose(id: string, shell: PlainShell): void { - if (this.shells.get(id) !== shell) { - return - } - this.shells.delete(id) - if (shell.exitCode === null && !shell.closeRequested) { - // Why: no exit-status means nothing proved the shell ended; report it lost, never exited. - this.emitData(id, shell, LOST_NOTICE) - return - } - this.exited.add(id) - const payload = { - id, - code: shell.exitCode ?? 0, - providerGeneration: this.providerGeneration, - ptyIncarnation: shell.incarnation, - incarnationId: shell.incarnation - } - for (const listener of this.exitListeners) { - listener(payload) - } - } - - private emitData(id: string, shell: PlainShell, data: string): void { - if (!data) { - return - } - const payload = { - id, - data, - providerGeneration: this.providerGeneration, - ptyIncarnation: shell.incarnation - } - for (const listener of this.dataListeners) { - listener(payload) - } - } - - async attach(id: string): Promise { - if (!this.shells.has(id)) { - throw new Error(`PTY ${id} not found`) - } - } - - hasPty = (id: string): boolean => this.shells.has(id) - - probePtyLiveness = async (id: string): Promise => { - if (this.shells.has(id)) { - return true - } - return this.exited.has(id) ? false : null - } - - write(id: string, data: string): boolean { - const shell = this.shells.get(id) - if (!shell) { - return false - } - shell.channel.write(data) - return true - } - - writeWithSettlement(id: string, data: string): WriteSettlement { - const shell = this.shells.get(id) - if (!shell) { - return { outcome: 'refused', reason: 'provider_unavailable' } - } - try { - shell.channel.write(data) - return WRITE_ACCEPTED - } catch { - return { - outcome: 'unverifiable', - reason: 'endpoint_write_threw', - bytesHandedToTransport: false - } - } - } - - resize(id: string, cols: number, rows: number): void { - const shell = this.shells.get(id) - if (!shell || cols < 1 || rows < 1) { - return - } - shell.channel.setWindow(rows, cols, 0, 0) - shell.cols = cols - shell.rows = rows - } - - getAppliedSize = async (id: string): Promise<{ cols: number; rows: number } | null> => { - const shell = this.shells.get(id) - return shell ? { cols: shell.cols, rows: shell.rows } : null - } - - async shutdown(id: string): Promise { - const shell = this.shells.get(id) - if (!shell) { - return - } - shell.closeRequested = true - shell.channel.close() - } - - async sendSignal(id: string, signal: string): Promise { - this.shells.get(id)?.channel.signal(signal.replace(/^SIG/, '')) - } - - async getCwd(id: string): Promise { - return this.shells.get(id)?.cwd ?? '' - } - - async getInitialCwd(id: string): Promise { - return this.shells.get(id)?.cwd ?? '' - } - - async clearBuffer(): Promise {} - async resetInputModes(): Promise {} - acknowledgeDataEvent(): void {} - - async hasChildProcesses(): Promise { - return false - } - - async getForegroundProcess(): Promise { - return null - } - - async serialize(): Promise { - return '[]' - } - - async revive(): Promise {} - - async listProcesses(): Promise { - return [...this.shells].map(([id, shell]) => ({ - id, - incarnationId: shell.incarnation, - cwd: shell.cwd, - title: 'ssh' - })) - } - - async getDefaultShell(): Promise { - return '' - } - - async getProfiles(): Promise<{ name: string; path: string }[]> { - return [] - } - - supportsAgentSessionClaims = (): boolean => false - supportsAgentSessionCreateOperations = (): boolean => false - canProvideAuthoritativeBufferSnapshot = (): boolean => false - - onData(callback: SshPtyDataCallback): () => void { - this.dataListeners.add(callback) - return () => this.dataListeners.delete(callback) - } - - onReplay(_callback: SshPtyReplayCallback): () => void { - return () => {} - } - - onExit(callback: SshPtyExitCallback): () => void { - this.exitListeners.add(callback) - return () => this.exitListeners.delete(callback) - } - - /** Transport teardown: every open shell becomes unverifiable; no exit is reported. */ - dispose(): void { - if (this.disposed) { - return - } - this.disposed = true - const open = [...this.shells.values()] - this.shells.clear() - for (const shell of open) { - try { - shell.channel.close() - } catch { - // The transport is already gone; nothing left to release. - } - } - this.dataListeners.clear() - this.exitListeners.clear() - } -} diff --git a/src/main/providers/ssh-review-draft-context.test.ts b/src/main/providers/ssh-review-draft-context.test.ts deleted file mode 100644 index de542c5c982b..000000000000 --- a/src/main/providers/ssh-review-draft-context.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { ReviewDraftContextError } from '../../shared/review-draft-context-error' -import { RelayContext } from '../../relay/context' -import { RelayDispatcher } from '../../wsl-guest/dispatcher' -import { GitHandler } from '../../relay/git-handler' -import { createGitTempDir, removeGitTempDir } from '../../relay/git-handler-test-harness' -import { gitCommit, gitInit } from '../../relay/git-handler-test-setup' -import { - encodeJsonRpcFrame, - FrameDecoder, - MessageType, - parseJsonRpcMessage -} from '../../wsl-guest/protocol' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux } from './ssh-git-provider-test-harness' -import { execSshReviewDraft } from './ssh-review-draft-context' - -const input = { - base: 'main', - currentTitle: 'Existing review title', - currentBody: 'Existing review body', - currentDraft: true -} -const mergeBase = 'a'.repeat(40) -const disposals: (() => void)[] = [] -const directories: string[] = [] - -afterEach(async () => { - disposals.splice(0).forEach((dispose) => dispose()) - await Promise.all(directories.splice(0).map(removeGitTempDir)) - vi.restoreAllMocks() - vi.unstubAllEnvs() -}) - -function providerForMux(mux: ReturnType): SshGitProvider { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness implements every request, stream notification, disposal and notify method used by these provider calls. - return new SshGitProvider('review-host', mux as unknown as SshChannelMultiplexer) -} - -function createWireProvider() { - const mux = createMockMux() - const pending = new Map< - number, - { resolve: (value: unknown) => void; reject: (error: Error) => void } - >() - const notifications = new Map) => void>>() - const decoder = new FrameDecoder((frame) => { - if (frame.type !== MessageType.Regular) { - return - } - const message = parseJsonRpcMessage(frame.payload) - if ('method' in message) { - notifications.get(message.method)?.forEach((listener) => listener(message.params ?? {})) - return - } - const request = pending.get(message.id) - if (!request) { - return - } - pending.delete(message.id) - if (message.error) { - request.reject(Object.assign(new Error(message.error.message), { code: message.error.code })) - } else { - request.resolve(message.result) - } - }) - const dispatcher = new RelayDispatcher((frame) => decoder.feed(frame)) - const handler = new GitHandler(dispatcher, new RelayContext()) - let sequence = 0 - mux.request.mockImplementation((method: string, params: Record) => { - const id = ++sequence - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) - dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) - }) - }) - mux.notify.mockImplementation((method: string, params: Record) => { - dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, ++sequence, 0)) - }) - mux.onNotificationByMethod.mockImplementation( - (method: string, listener: (params: Record) => void) => { - const listeners = notifications.get(method) ?? new Set() - notifications.set(method, listeners) - listeners.add(listener) - return () => listeners.delete(listener) - } - ) - disposals.push(() => { - handler.dispose() - dispatcher.dispose() - pending.forEach((request) => request.reject(new Error('Test relay disposed'))) - pending.clear() - }) - return { provider: providerForMux(mux), mux, dispatcher, notifications } -} - -async function git(cwd: string, args: string[]) { - const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 10_000 }) - if (result.code !== 0) { - throw Object.assign(new Error(result.stderr), { code: result.code }) - } - return { stdout: result.stdout, stderr: result.stderr } -} - -async function createReviewFixture(large: boolean) { - const root = createGitTempDir() - directories.push(root) - const globalConfig = join(root, 'global.gitconfig') - await writeFile(globalConfig, '') - // Newer desktop Git preferences may be rejected while a baseline fixture loads config. - vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig) - vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') - const remote = join(root, 'remote repo') - const worktree = join(root, 'review worktree') - await mkdir(remote) - gitInit(remote) - await git(remote, ['checkout', '-b', 'main']) - await writeFile(join(remote, 'README.md'), 'Original content\n') - gitCommit(remote, 'Initial base') - await git(root, ['clone', remote, worktree]) - const originalBase = (await git(worktree, ['rev-parse', 'HEAD'])).stdout.trim() - await writeFile(join(remote, 'base-only.txt'), 'Advance the remote base after cloning\n') - gitCommit(remote, 'Advance base') - await git(worktree, ['checkout', '-b', 'feature/review']) - await writeFile(join(worktree, 'README.md'), 'Committed review evidence\n') - await writeFile( - join(worktree, 'new file.txt'), - large - ? 'Committed line with unicode café for a streamed review.\n'.repeat(36_000) - : 'New evidence\n' - ) - gitCommit(worktree, 'Add review evidence') - await writeFile(join(worktree, 'README.md'), 'Uncommitted content must stay out of the draft\n') - return { worktree, originalBase } -} - -describe('SSH review draft through the real relay dispatcher', () => { - it.each([false, true])('matches complete local context with streamed patch=%s', async (large) => { - const { worktree, originalBase } = await createReviewFixture(large) - const local = await getPullRequestDraftContext((args) => git(worktree, args), input) - expect(local).toMatchObject({ - branch: 'feature/review', - commitSummary: '- Add review evidence', - changeSummary: expect.stringContaining('new file.txt'), - patch: expect.stringContaining('+Committed review evidence') - }) - await git(worktree, ['update-ref', 'refs/remotes/origin/main', originalBase]) - const { provider, mux, notifications } = createWireProvider() - - const remote = await getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, worktree, options), - input - ) - - expect(remote).toEqual(local) - expect(remote?.patch).not.toContain('Uncommitted content') - expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', { - worktreePath: worktree, - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/main' - }) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.reviewDiff')).toHaveLength(2) - expect( - mux.request.mock.calls.some( - ([method, params]) => - method === 'git.exec' && (params.args[0] === 'fetch' || params.args[0] === 'diff') - ) - ).toBe(false) - expect([...notifications.values()].every((listeners) => listeners.size === 0)).toBe(true) - if (large) { - expect(mux.notify).toHaveBeenCalledWith('git.responseAck', expect.any(Object)) - } - }) -}) - -function createContextProvider() { - const mux = createMockMux() - const runRequest = async (method: string, params: Record) => { - if (method === 'git.fetchRemoteTrackingRef') { - return undefined - } - if (method === 'git.reviewDiff') { - return { stdout: 'remote evidence', stderr: '' } - } - const args = params.args - if (!Array.isArray(args)) { - throw new Error('Missing context Git arguments') - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'branch') { - return { stdout: 'feature/review\n', stderr: '' } - } - if (args[0] === 'merge-base') { - return { stdout: `${mergeBase}\n`, stderr: '' } - } - if (args[0] === 'log') { - return { stdout: '- committed evidence\n', stderr: '' } - } - throw new Error(`Unexpected method ${method}`) - } - mux.request.mockImplementation(runRequest) - return { mux, provider: providerForMux(mux), runRequest } -} - -describe('SSH review draft failures and routing', () => { - it('rejects an old-host missing diff method instead of returning commit-only context', async () => { - const { mux, provider, runRequest } = createContextProvider() - mux.request.mockImplementation((method, params) => { - if (method === 'git.reviewDiff') { - return Promise.reject(Object.assign(new Error('Method not found'), { code: -32601 })) - } - return runRequest(method, params) - }) - - await expect( - getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, '/repo', options), - input - ) - ).rejects.toThrow('Reconnect the SSH target') - }) - - it.each(['SSH connection closed', 'Git request timed out', 'git output exceeded maxBuffer.'])( - 'rejects incomplete context after %s', - async (message) => { - const { mux, provider, runRequest } = createContextProvider() - mux.request.mockImplementation((method, params) => { - if (method === 'git.reviewDiff') { - return Promise.reject(new Error(message)) - } - return runRequest(method, params) - }) - const draft = getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, '/repo', options), - input - ) - await expect(draft).rejects.toBeInstanceOf(ReviewDraftContextError) - await expect(draft).rejects.toThrow(message) - } - ) - - it('stops before reading evidence when an old host lacks the narrow fetch method', async () => { - const { mux, provider, runRequest } = createContextProvider() - mux.request.mockImplementation((method, params) => { - if (method === 'git.fetchRemoteTrackingRef') { - return Promise.reject( - Object.assign(new Error('Method not found: git.fetchRemoteTrackingRef'), { code: -32601 }) - ) - } - return runRequest(method, params) - }) - await expect( - getPullRequestDraftContext( - (args, options) => execSshReviewDraft(provider, args, '/repo', options), - input - ) - ).rejects.toThrow('Fetch before generating PR details failed') - expect(mux.request.mock.calls.some(([method]) => method === 'git.reviewDiff')).toBe(false) - }) - - it.each([ - undefined, - null, - {}, - { stdout: 'patch' }, - { stdout: 3, stderr: '' }, - { stdout: '', stderr: null } - ])('refuses malformed remote evidence %j', async (reply) => { - const mux = createMockMux() - mux.request.mockResolvedValue(reply) - await expect(providerForMux(mux).readReviewDiff('/repo', mergeBase, 'patch')).rejects.toThrow( - ReviewDraftContextError - ) - }) - - it.each([{ timeout: 321 }, { timeoutMs: 654 }, { timeout: 321, timeoutMs: 654 }])( - 'forwards draft deadlines %j to the narrow diff request', - async (options) => { - const mux = createMockMux() - mux.request.mockResolvedValue({ stdout: 'patch', stderr: '' }) - await execSshReviewDraft( - providerForMux(mux), - ['diff', '--name-status', `${mergeBase}..HEAD`], - '/repo', - options - ) - expect(mux.request).toHaveBeenCalledWith( - 'git.reviewDiff', - { - worktreePath: '/repo', - mergeBase, - format: 'name-status', - __streamResponse: true - }, - { signal: undefined, timeoutMs: options.timeoutMs ?? options.timeout } - ) - } - ) - - it('keeps non-draft fetch and diff shapes on the restricted generic path', async () => { - const mux = createMockMux() - mux.request.mockResolvedValue({ stdout: '', stderr: '' }) - const provider = providerForMux(mux) - for (const args of [ - ['fetch', '--all'], - ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/heads/overwrite'], - ['diff', '--name-status', '--no-ext-diff', `${mergeBase}..HEAD`], - ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', `${mergeBase}...HEAD`] - ]) { - await execSshReviewDraft(provider, args, '/repo', { timeoutMs: 456 }) - expect(mux.request).toHaveBeenLastCalledWith( - 'git.exec', - { - args, - cwd: '/repo', - __streamResponse: true - }, - { signal: undefined, timeoutMs: 456 } - ) - } - expect(mux.request.mock.calls.every(([method]) => method === 'git.exec')).toBe(true) - }) -}) diff --git a/src/main/providers/ssh-sftp-directory-listing.ts b/src/main/providers/ssh-sftp-directory-listing.ts deleted file mode 100644 index eb8436ea015a..000000000000 --- a/src/main/providers/ssh-sftp-directory-listing.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import type { DirEntry } from '../../shared/filesystem-entry-types' -import { DirectoryListingBudget } from '../../shared/directory-listing-budget' -import { sortDirEntries } from '../../shared/file-name-sort' -import { readDirectoryEntriesViaSftp, statViaSftp } from './ssh-filesystem-provider-sftp' - -export async function readSftpDirectory( - sftp: SFTPWrapper, - path: string, - options?: { followSymlinks?: boolean; signal?: AbortSignal } -): Promise { - const budget = new DirectoryListingBudget() - const mapped: DirEntry[] = [] - for await (const entry of readDirectoryEntriesViaSftp(sftp, path, options)) { - budget.record(entry.filename) - const isSymlink = entry.attrs.isSymbolicLink() - let isDirectory = entry.attrs.isDirectory() - if (isSymlink && options?.followSymlinks !== false) { - isDirectory = await statViaSftp(sftp, `${path.replace(/\/$/, '')}/${entry.filename}`, options) - .then((stats) => stats.isDirectory()) - .catch(() => false) - } - mapped.push({ name: entry.filename, isDirectory, isSymlink }) - } - options?.signal?.throwIfAborted() - return sortDirEntries(mapped) -} diff --git a/src/main/providers/ssh-sftp-filesystem-channel.ts b/src/main/providers/ssh-sftp-filesystem-channel.ts deleted file mode 100644 index aaef5c9c2c17..000000000000 --- a/src/main/providers/ssh-sftp-filesystem-channel.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import type { SftpFactory } from './ssh-filesystem-download' -import { isSftpDirectoryChannelRetired } from './ssh-sftp-directory-close' - -export class SftpFilesystemChannel { - private sftpPromise: Promise | null = null - private disposed = false - - constructor(private readonly createSftp: SftpFactory) {} - - dispose(): void { - this.disposed = true - const pending = this.sftpPromise - this.sftpPromise = null - void pending?.then( - (sftp) => sftp.end(), - () => {} - ) - } - - async get(): Promise { - if (this.disposed) { - throw new Error('SSH connection is not active') - } - if (!this.sftpPromise) { - const opening = this.createSftp().then((sftp) => { - if (isSftpDirectoryChannelRetired(sftp)) { - throw new Error('SFTP factory returned a retired directory channel') - } - // Why: a closed channel must not be reused; the next call reopens one. - sftp.once('close', () => { - if (this.sftpPromise === opening) { - this.sftpPromise = null - } - }) - return sftp - }) - opening.catch(() => { - if (this.sftpPromise === opening) { - this.sftpPromise = null - } - }) - this.sftpPromise = opening - } - const opening = this.sftpPromise - const sftp = await opening - if (isSftpDirectoryChannelRetired(sftp)) { - if (this.sftpPromise === opening) { - this.sftpPromise = null - } - return this.get() - } - return sftp - } -} diff --git a/src/main/providers/ssh-sftp-filesystem-provider.test.ts b/src/main/providers/ssh-sftp-filesystem-provider.test.ts deleted file mode 100644 index f2093ce48dee..000000000000 --- a/src/main/providers/ssh-sftp-filesystem-provider.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' -import type { SFTPWrapper } from 'ssh2' -import { SshSftpFilesystemProvider, toSftpPath } from './ssh-sftp-filesystem-provider' -import { downloadFolderViaSftp } from './ssh-filesystem-download' -import type * as SshFilesystemDownload from './ssh-filesystem-download' - -vi.mock('./ssh-filesystem-download', async (importOriginal) => ({ - ...(await importOriginal()), - downloadFolderViaSftp: vi.fn(async () => {}) -})) - -type Node = { kind: 'file'; content: Buffer } | { kind: 'dir' } | { kind: 'link'; target: string } -type Callback = (err: Error | null, value?: unknown) => void - -function stats(node: Node, size = 0) { - return { - size, - mtime: 1_700_000_000, - isDirectory: () => node.kind === 'dir', - isSymbolicLink: () => node.kind === 'link', - isFile: () => node.kind === 'file' - } -} - -function noSuchFile(): Error { - return Object.assign(new Error('No such file'), { code: 2 }) -} - -/** In-memory SFTP server double covering the calls the provider makes. */ -class FakeSftp extends EventEmitter { - readonly nodes = new Map() - readonly end = vi.fn() - posixRename = true - - private resolve(path: string): Node | undefined { - const node = this.nodes.get(path) - return node?.kind === 'link' ? this.nodes.get(node.target) : node - } - - private sizeOf(node: Node): number { - return node.kind === 'file' ? node.content.length : 0 - } - - private directoryReads = new WeakSet() - - opendir(path: string, cb: Callback): void { - cb(null, Buffer.from(path)) - } - close(_handle: Buffer, cb: Callback): void { - cb(null) - } - - readdir(handle: Buffer, cb: Callback): void { - if (this.directoryReads.has(handle)) { - return cb(null, false) - } - this.directoryReads.add(handle) - const path = handle.toString() - const prefix = `${path}/` - const entries = [...this.nodes] - .filter(([p]) => p.startsWith(prefix) && !p.slice(prefix.length).includes('/')) - .map(([p, node]) => ({ filename: p.slice(prefix.length), attrs: stats(node) })) - cb(null, entries) - } - - stat(path: string, cb: Callback): void { - const node = this.resolve(path) - return node ? cb(null, stats(node, this.sizeOf(node))) : cb(noSuchFile()) - } - - lstat(path: string, cb: Callback): void { - const node = this.nodes.get(path) - return node ? cb(null, stats(node, this.sizeOf(node))) : cb(noSuchFile()) - } - - readFile(path: string, cb: Callback): void { - const node = this.resolve(path) - return node?.kind === 'file' ? cb(null, node.content) : cb(noSuchFile()) - } - - writeFile(path: string, data: string | Buffer, cb: Callback): void { - this.nodes.set(path, { kind: 'file', content: Buffer.from(data) }) - cb(null) - } - - appendFile(path: string, data: Buffer, cb: Callback): void { - const node = this.nodes.get(path) - const prior = node?.kind === 'file' ? node.content : Buffer.alloc(0) - this.nodes.set(path, { kind: 'file', content: Buffer.concat([prior, data]) }) - cb(null) - } - - unlink(path: string, cb: Callback): void { - this.nodes.delete(path) - cb(null) - } - - rmdir(path: string, cb: Callback): void { - const hasChildren = [...this.nodes.keys()].some((p) => p.startsWith(`${path}/`)) - if (hasChildren) { - cb(Object.assign(new Error('Failure'), { code: 4 })) - return - } - this.nodes.delete(path) - cb(null) - } - - mkdir(path: string, cb: Callback): void { - this.nodes.set(path, { kind: 'dir' }) - cb(null) - } - - rename(from: string, to: string, cb: Callback): void { - if (this.nodes.has(to)) { - cb(Object.assign(new Error('Failure'), { code: 4 })) - return - } - this.nodes.set(to, this.nodes.get(from)!) - this.nodes.delete(from) - cb(null) - } - - ext_openssh_rename(from: string, to: string, cb: Callback): void { - if (!this.posixRename) { - throw new Error('Server does not support this extended request') - } - this.nodes.set(to, this.nodes.get(from)!) - this.nodes.delete(from) - cb(null) - } - - realpath(path: string, cb: Callback): void { - cb(null, path === '.' ? '/home/me' : path) - } -} - -const MODE = { reason: 'home_noexec', message: 'noexec home' } - -function createProvider() { - const sftp = new FakeSftp() - const createSftp = vi.fn(async () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSftp implements every SFTPWrapper method the provider calls. - return sftp as unknown as SFTPWrapper - }) - const provider = new SshSftpFilesystemProvider('target-1', createSftp, MODE) - return { provider, sftp, createSftp } -} - -describe('SshSftpFilesystemProvider', () => { - it('maps home-relative paths onto the SFTP login directory', () => { - expect(toSftpPath('~')).toBe('.') - expect(toSftpPath('~/src/app')).toBe('src/app') - expect(toSftpPath('/etc/hosts')).toBe('/etc/hosts') - }) - - it('lists a directory sorted, resolving symlinked directories like their target', async () => { - const { provider, sftp, createSftp } = createProvider() - sftp.nodes.set('/w/b.txt', { kind: 'file', content: Buffer.from('b') }) - sftp.nodes.set('/w/src', { kind: 'dir' }) - sftp.nodes.set('/w/link', { kind: 'link', target: '/w/src' }) - sftp.nodes.set('/w/dangling', { kind: 'link', target: '/nowhere' }) - - await expect(provider.readDir('/w')).resolves.toEqual([ - { name: 'link', isDirectory: true, isSymlink: true }, - { name: 'src', isDirectory: true, isSymlink: false }, - { name: 'b.txt', isDirectory: false, isSymlink: false }, - { name: 'dangling', isDirectory: false, isSymlink: true } - ]) - await provider.stat('/w/b.txt') - // One SFTP channel is reused across operations. - expect(createSftp).toHaveBeenCalledTimes(1) - }) - - it('reads text, flags binary, and returns images as base64', async () => { - const { provider, sftp } = createProvider() - sftp.nodes.set('/a.txt', { kind: 'file', content: Buffer.from('héllo') }) - sftp.nodes.set('/a.bin', { kind: 'file', content: Buffer.from([1, 0, 2]) }) - sftp.nodes.set('/a.png', { kind: 'file', content: Buffer.from([137, 80]) }) - - await expect(provider.readFile('/a.txt')).resolves.toEqual({ - content: 'héllo', - isBinary: false - }) - await expect(provider.readFile('/a.bin')).resolves.toEqual({ content: '', isBinary: true }) - await expect(provider.readFile('/a.png')).resolves.toMatchObject({ - content: Buffer.from([137, 80]).toString('base64'), - isImage: true, - mimeType: 'image/png' - }) - }) - - it('reports missing paths as ENOENT so existence checks stay truthful', async () => { - const { provider, sftp } = createProvider() - sftp.nodes.set('/here', { kind: 'file', content: Buffer.from('') }) - await expect(provider.stat('/gone')).rejects.toMatchObject({ code: 'ENOENT' }) - await expect(provider.pathsExist(['/here', '/gone'])).resolves.toEqual([ - { exists: true }, - { exists: false } - ]) - }) - - it('writes, appends, creates and renames files', async () => { - const { provider, sftp } = createProvider() - await provider.writeFile('/f', 'one') - await provider.writeFileBase64Chunk('/f', Buffer.from('two').toString('base64'), true) - expect(sftp.nodes.get('/f')).toEqual({ kind: 'file', content: Buffer.from('onetwo') }) - - await provider.createDir('/d') - await provider.rename('/f', '/d/f') - expect(sftp.nodes.has('/d/f')).toBe(true) - - sftp.posixRename = false - await provider.writeFile('/g', 'g') - await provider.rename('/g', '/h') - expect(sftp.nodes.has('/h')).toBe(true) - await expect(provider.renameNoClobber('/h', '/d/f')).rejects.toThrow('Failure') - }) - - it('deletes files and empty folders but refuses a recursive delete with the reason', async () => { - const { provider, sftp } = createProvider() - sftp.nodes.set('/d', { kind: 'dir' }) - sftp.nodes.set('/d/x', { kind: 'file', content: Buffer.from('x') }) - await expect(provider.deletePath('/d', true)).rejects.toThrow( - 'Deleting a non-empty folder needs the Orca remote server' - ) - await provider.deletePath('/d/x') - await provider.deletePath('/d') - expect(sftp.nodes.size).toBe(0) - }) - - it('refuses relay-only features with the classified reason', async () => { - const { provider } = createProvider() - await expect(provider.watch()).rejects.toThrow('(home_noexec)') - await expect(provider.search()).rejects.toThrow('Search needs the Orca remote server') - await expect(provider.listFiles()).rejects.toThrow('Quick Open') - await expect(provider.copy()).rejects.toThrow('Copying files') - await expect(provider.supportsQuickOpenSearch()).resolves.toBe(false) - }) - - it('downloads folders over SFTP with the host path style', async () => { - const { createSftp } = createProvider() - const windowsProvider = new SshSftpFilesystemProvider('target-1', createSftp, MODE, true) - const signal = new AbortController().signal - await windowsProvider.downloadFolder('~/proj', '/tmp/dest', { signal }) - expect(downloadFolderViaSftp).toHaveBeenCalledWith(createSftp, 'proj', '/tmp/dest', { - signal, - windowsRemotePaths: true - }) - }) - - it('probes the transport with one SFTP round trip and times out a silent one', async () => { - const { provider, sftp } = createProvider() - await expect(provider.probeTransport(1_000)).resolves.toBe(true) - sftp.realpath = () => {} - vi.useFakeTimers() - try { - const probe = provider.probeTransport(5_000) - await vi.advanceTimersByTimeAsync(5_000) - await expect(probe).resolves.toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('reopens SFTP after the channel closes and ends it on dispose', async () => { - const { provider, sftp, createSftp } = createProvider() - sftp.nodes.set('/x', { kind: 'file', content: Buffer.from('') }) - await provider.stat('/x') - sftp.emit('close') - await provider.stat('/x') - expect(createSftp).toHaveBeenCalledTimes(2) - - provider.dispose() - await Promise.resolve() - expect(sftp.end).toHaveBeenCalled() - await expect(provider.stat('/x')).rejects.toThrow('not active') - }) -}) - -it('retires the persistent directory channel after failed CLOSE and reopens for the next read', async () => { - const first = new FakeSftp() - first.nodes.set('/dir/file.txt', { kind: 'file', content: Buffer.from('content') }) - first.close = (_handle, callback) => callback(new Error('CLOSE rejected')) - const second = new FakeSftp() - second.nodes.set('/dir/file.txt', { kind: 'file', content: Buffer.from('content') }) - const createSftp = vi.fn(async () => { - const next = createSftp.mock.calls.length === 1 ? first : second - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSftp implements the directory operations and channel close event used by this provider. - return next as unknown as SFTPWrapper - }) - const provider = new SshSftpFilesystemProvider('target-1', createSftp, MODE) - await expect(provider.readDir('/dir')).rejects.toThrow('CLOSE rejected') - expect(first.end).toHaveBeenCalledOnce() - await expect(provider.readDir('/dir')).resolves.toEqual([ - { name: 'file.txt', isDirectory: false, isSymlink: false } - ]) - expect(createSftp).toHaveBeenCalledTimes(2) - expect(second.end).not.toHaveBeenCalled() - first.emit('close') - await expect(provider.readDir('/dir')).resolves.toHaveLength(1) - expect(createSftp).toHaveBeenCalledTimes(2) - provider.dispose() -}) - -it('replaces a timed-out channel immediately without waiting for its close event', async () => { - vi.useFakeTimers() - try { - const first = new FakeSftp() - first.close = () => {} - const second = new FakeSftp() - const createSftp = vi.fn(async () => { - const next = createSftp.mock.calls.length === 1 ? first : second - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSftp provides handle operations and channel lifecycle events used by the directory provider. - return next as unknown as SFTPWrapper - }) - const provider = new SshSftpFilesystemProvider('target-1', createSftp, MODE) - const rejected = expect(provider.readDir('/dir')).rejects.toThrow('CLOSE timed out') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(first.end).toHaveBeenCalledOnce() - const [a, b] = await Promise.all([provider.readDir('/dir'), provider.readDir('/dir')]) - expect(a).toEqual([]) - expect(b).toEqual([]) - expect(createSftp).toHaveBeenCalledTimes(2) - first.emit('close') - await expect(provider.readDir('/dir')).resolves.toEqual([]) - expect(createSftp).toHaveBeenCalledTimes(2) - provider.dispose() - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/providers/ssh-sftp-filesystem-provider.ts b/src/main/providers/ssh-sftp-filesystem-provider.ts deleted file mode 100644 index ddbcabe951fa..000000000000 --- a/src/main/providers/ssh-sftp-filesystem-provider.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { readSftpDirectory } from './ssh-sftp-directory-listing' -import { SftpFilesystemChannel } from './ssh-sftp-filesystem-channel' -export { readSftpDirectory } from './ssh-sftp-directory-listing' -/** - * Filesystem provider for plain SSH mode (design D6 rung D): read, list, stat and write over - * one reused SFTP channel. Anything that needs the Orca remote server (search, file lists, - * watching, recursive delete, copy) refuses with that reason instead of hanging on a relay. - */ -import { extname } from 'node:path' -import type { SFTPWrapper, Stats } from 'ssh2' -import { IMAGE_FILE_MIME_TYPES } from '../../shared/image-file-extensions' -import { isBinaryBuffer } from '../../shared/binary-buffer' -import { capturePathExistence, type PathExistenceResult } from '../../shared/path-existence-batch' -import type { SearchResult } from '../../shared/code-search-types' -import type { DirEntry } from '../../shared/filesystem-entry-types' -import type { SshPlainSshMode } from '../../shared/ssh-types' -import { PlainSshUnsupportedError } from '../ssh/ssh-plain-ssh-mode' -import { - downloadFileViaSftp, - downloadFolderViaSftp, - type FolderDownloadOptions, - type SftpFactory -} from './ssh-filesystem-download' -import { fileStatFromSftpStats, lstatViaSftp, statViaSftp } from './ssh-filesystem-provider-sftp' -import type { FileReadLimits, FileReadResult, FileStat, IFilesystemProvider } from './types' - -// Why: same caps as the relay's fs.readFile so previews behave identically. -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const SFTP_NO_SUCH_FILE = 2 -const PREVIEW_MIME_TYPES: Record = { - ...IMAGE_FILE_MIME_TYPES, - '.pdf': 'application/pdf' -} - -/** SFTP resolves relative paths against the login directory, which is `~`. */ -export function toSftpPath(path: string): string { - if (path === '~') { - return '.' - } - return path.startsWith('~/') ? path.slice(2) || '.' : path -} - -function errorCode(error: unknown): unknown { - return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined -} - -function normalizeSftpError(error: unknown): Error { - const err = error instanceof Error ? error : new Error(String(error)) - if (errorCode(err) === SFTP_NO_SUCH_FILE) { - return Object.assign(new Error(`ENOENT: no such file or directory: ${err.message}`), { - code: 'ENOENT' - }) - } - return err -} - -export class SshSftpFilesystemProvider implements IFilesystemProvider { - private readonly channel: SftpFilesystemChannel - - constructor( - private readonly connectionId: string, - private readonly createSftp: SftpFactory, - private readonly mode: SshPlainSshMode, - private readonly windowsRemotePaths = false - ) { - this.channel = new SftpFilesystemChannel(createSftp) - } - - getConnectionId(): string { - return this.connectionId - } - - dispose(): void { - this.channel.dispose() - } - - /** Round-trips one SFTP request; a silent transport times out as not alive. */ - async probeTransport(timeoutMs: number): Promise { - let timer: ReturnType | undefined - const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(false), timeoutMs) - }) - try { - return await Promise.race([ - this.realpath('.').then( - () => true, - () => false - ), - timeout - ]) - } finally { - clearTimeout(timer) - } - } - - private async run(op: (sftp: SFTPWrapper) => Promise): Promise { - try { - return await op(await this.channel.get()) - } catch (error) { - throw normalizeSftpError(error) - } - } - - private call( - register: (sftp: SFTPWrapper, cb: (err: Error | null | undefined, value?: T) => void) => void - ): Promise { - return this.run( - (sftp) => - new Promise((resolve, reject) => { - register(sftp, (err, value) => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes the operation's value with every successful callback; void operations resolve undefined. - err ? reject(err) : resolve(value as T) - ) - }) - ) - } - - private unsupported(feature: string): PlainSshUnsupportedError { - return new PlainSshUnsupportedError(feature, this.mode) - } - - async readDir(dirPath: string, options?: { followSymlinks?: boolean }): Promise { - const path = toSftpPath(dirPath) - return this.run(async (sftp) => { - return readSftpDirectory(sftp, path, options) - }) - } - - async readFile(filePath: string, _limits?: FileReadLimits): Promise { - const path = toSftpPath(filePath) - return this.run(async (sftp) => { - const stats = await statViaSftp(sftp, path) - const mimeType = PREVIEW_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - const buffer = await new Promise((resolve, reject) => - sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data))) - ) - if (mimeType) { - return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } - }) - } - - async downloadFile(sourcePath: string, destinationPath: string): Promise { - await downloadFileViaSftp(this.createSftp, toSftpPath(sourcePath), destinationPath) - } - - // Why: the connect broadcast advertises folder download for every ssh2 target, plain SSH included. - async downloadFolder( - sourcePath: string, - destinationPath: string, - options?: FolderDownloadOptions - ): Promise { - await downloadFolderViaSftp(this.createSftp, toSftpPath(sourcePath), destinationPath, { - signal: options?.signal, - windowsRemotePaths: this.windowsRemotePaths - }) - } - - async writeFile(filePath: string, content: string): Promise { - await this.call((sftp, cb) => sftp.writeFile(toSftpPath(filePath), content, cb)) - } - - async writeFileBase64(filePath: string, contentBase64: string): Promise { - const data = Buffer.from(contentBase64, 'base64') - await this.call((sftp, cb) => sftp.writeFile(toSftpPath(filePath), data, cb)) - } - - async writeFileBase64Chunk( - filePath: string, - contentBase64: string, - append: boolean - ): Promise { - const data = Buffer.from(contentBase64, 'base64') - const path = toSftpPath(filePath) - await this.call((sftp, cb) => - append ? sftp.appendFile(path, data, cb) : sftp.writeFile(path, data, cb) - ) - } - - async pathsExist(filePaths: string[]): Promise { - return Promise.all( - filePaths.map((filePath) => - capturePathExistence(() => - this.stat(filePath).then( - () => true, - (error: unknown) => { - if (errorCode(error) === 'ENOENT') { - return false - } - throw error - } - ) - ) - ) - ) - } - - async stat(filePath: string): Promise { - return this.run(async (sftp) => - fileStatFromSftpStats(await statViaSftp(sftp, toSftpPath(filePath))) - ) - } - - async lstat(filePath: string): Promise { - return this.run((sftp) => lstatViaSftp(sftp, toSftpPath(filePath))) - } - - async deletePath(targetPath: string, recursive?: boolean): Promise { - const path = toSftpPath(targetPath) - const stats = await this.run( - (sftp) => - new Promise((resolve, reject) => - sftp.lstat(path, (err, value) => (err ? reject(err) : resolve(value))) - ) - ) - if (!stats.isDirectory()) { - await this.call((sftp, cb) => sftp.unlink(path, cb)) - return - } - try { - await this.call((sftp, cb) => sftp.rmdir(path, cb)) - } catch (error) { - // Why: a non-empty directory needs a recursive walk the relay does atomically on the host. - throw recursive ? this.unsupported('Deleting a non-empty folder') : error - } - } - - async createFile(filePath: string): Promise { - const path = toSftpPath(filePath) - const handle = await this.call((sftp, cb) => sftp.open(path, 'wx', cb)) - await this.call((sftp, cb) => sftp.close(handle, cb)) - } - - async createDir(dirPath: string): Promise { - await this.call((sftp, cb) => sftp.mkdir(toSftpPath(dirPath), cb)) - } - - async createDirNoClobber(dirPath: string): Promise { - await this.createDir(dirPath) - } - - async rename(oldPath: string, newPath: string): Promise { - const from = toSftpPath(oldPath) - const to = toSftpPath(newPath) - await this.call((sftp, cb) => { - try { - // Why: SFTPv3 rename refuses an existing target; the OpenSSH extension overwrites. - sftp.ext_openssh_rename(from, to, cb) - } catch { - sftp.rename(from, to, cb) - } - }) - } - - async renameNoClobber(oldPath: string, newPath: string): Promise { - await this.call((sftp, cb) => sftp.rename(toSftpPath(oldPath), toSftpPath(newPath), cb)) - } - - async copy(): Promise { - throw this.unsupported('Copying files') - } - - async realpath(filePath: string): Promise { - return this.call((sftp, cb) => sftp.realpath(toSftpPath(filePath), cb)) - } - - async search(): Promise { - throw this.unsupported('Search') - } - - async listFiles(): Promise { - throw this.unsupported('Quick Open') - } - - async supportsQuickOpenSearch(): Promise { - return false - } - - async supportsFileRangeRead(): Promise { - return false - } - - async watch(): Promise<() => void> { - throw this.unsupported('Watching files for changes') - } -} diff --git a/src/main/providers/terminal-path-existence-batch.integration.test.ts b/src/main/providers/terminal-path-existence-batch.integration.test.ts index c67b627d6829..21b09788ebe0 100644 --- a/src/main/providers/terminal-path-existence-batch.integration.test.ts +++ b/src/main/providers/terminal-path-existence-batch.integration.test.ts @@ -2,10 +2,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { pathsExistOnRelay } from '../../relay/fs-path-existence' -import { statRelayPath } from '../../relay/fs-path-metadata-requests' -import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' const handlers = vi.hoisted(() => new Map Promise>()) vi.mock('electron', () => ({ ipcMain: { @@ -46,68 +42,3 @@ it('one actual shell IPC handler probes eight distinct temporary files and retai 'Invalid' ) }) -it('one real relay batch serves eight distinct SSH paths after one shared capability probe', async () => { - const paths = await fixture() - const request = vi.fn(async (method: string, params: Record) => - method === 'fs.getCapabilities' ? { pathExistenceBatchVersion: 1 } : pathsExistOnRelay(params) - ) - const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - const mux = { request } as never - expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( - paths.map(() => ({ exists: true })) - ) - expect(request.mock.calls.map((c) => c[0])).toEqual(['fs.getCapabilities', 'fs.pathsExist']) - expect(scalar).not.toHaveBeenCalled() - await rm(paths[0]) - expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: false }]) - await writeFile(paths[0], 'new') - expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: true }]) - expect(request.mock.calls.filter((c) => c[0] === 'fs.getCapabilities')).toHaveLength(1) -}) -it('old relay falls back on the same host without retrying a missing capability document', async () => { - const paths = await fixture() - const request = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - const mux = { request } as never - expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( - paths.map(() => ({ exists: true })) - ) - expect(scalar).toHaveBeenCalledTimes(8) - await readSshPathExistenceBatch(mux, [paths[0]], scalar) - expect(request).toHaveBeenCalledTimes(1) -}) -it('connection failure is neither a missing path nor permission to use local/scalar fallback', async () => { - const scalar = vi.fn() - const request = vi.fn().mockRejectedValue(new Error('connection closed')) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - const mux = { request } as never - await expect(readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).rejects.toThrow( - 'connection closed' - ) - expect(scalar).not.toHaveBeenCalled() - request - .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) - .mockResolvedValueOnce([{ error: 'EACCES denied' }]) - expect(await readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).toEqual([ - { error: 'EACCES denied' } - ]) - expect(request).toHaveBeenCalledTimes(3) -}) -it('malformed batch replies fail rather than manufacturing negative cache entries', async () => { - const scalar = vi.fn() - const request = vi - .fn() - .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) - .mockResolvedValueOnce([]) - await expect( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - readSshPathExistenceBatch({ request } as never, ['/remote/path'], scalar) - ).rejects.toThrow('Invalid path existence response') - expect(scalar).not.toHaveBeenCalled() -}) diff --git a/src/main/provisioned-root-checkout.ts b/src/main/provisioned-root-checkout.ts new file mode 100644 index 000000000000..07b7a56e21e4 --- /dev/null +++ b/src/main/provisioned-root-checkout.ts @@ -0,0 +1,173 @@ +/** The host-independent half of adopting a recipe's provisioned root: checkout proof and metadata. */ +import { randomUUID } from 'node:crypto' +import type { Store } from './persistence' +import type { Repo } from '../shared/repo-types' +import type { CreateWorktreeArgs } from '../shared/worktree/create-types' +import type { ExecutionHostId } from '../shared/execution-host' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import type { AutomationWorkspaceProvenance, GitWorktreeInfo } from '../shared/worktree/types' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup' +import { isTuiAgent } from '../shared/tui-agent-config' +import { + getWorktreeCreationLayout, + resolveWorktreeCreateDisplayNameMeta, + resolveWorktreeCreateDisplayNameRequest +} from './ipc/worktree-logic' + +export type ProvisionedRootRequest = Pick< + CreateWorktreeArgs, + | 'name' + | 'nameWasGenerated' + | 'displayName' + | 'displayNameKind' + | 'baseBranch' + | 'compareBaseRef' + | 'branchNameOverride' + | 'sparseCheckout' + | 'linkedIssue' + | 'linkedPR' + | 'linkedLinearIssue' + | 'linkedLinearIssueWorkspaceId' + | 'linkedLinearIssueOrganizationUrlKey' + | 'linkedGitLabIssue' + | 'linkedGitLabMR' + | 'linkedBitbucketPR' + | 'linkedAzureDevOpsPR' + | 'linkedGiteaPR' + | 'linkedWorkItem' + | 'linkedItems' + | 'linkedTaskSourceContext' + | 'pushTarget' + | 'workspaceStatus' + | 'manualOrder' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' +> & { + expectedRefHead?: string + automationProvenance?: AutomationWorkspaceProvenance +} + +export function provisionedRootPathsEqual(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + +/** The project root must be the repo's only, primary, non-sparse checkout on the requested branch and ref. */ +export function verifyProvisionedRootCheckout(args: { + worktrees: readonly GitWorktreeInfo[] + sparseCheckoutEnabled: boolean + projectRoot: string + request: Pick & { + expectedRefHead?: string + } +}): GitWorktreeInfo { + const { request } = args + const matches = args.worktrees.filter((worktree) => + provisionedRootPathsEqual(worktree.path, args.projectRoot) + ) + if (matches.length !== 1) { + throw new Error('The recipe projectRoot is not a unique Git checkout root.') + } + const gitWorktree = matches[0] + if (!gitWorktree.isMainWorktree) { + throw new Error('The recipe projectRoot must be the repository primary checkout.') + } + if (gitWorktree.isBare) { + throw new Error('Provisioned-root recipes cannot adopt a bare repository.') + } + if (gitWorktree.isSparse || args.sparseCheckoutEnabled) { + throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.') + } + const requestedBranch = request.branchNameOverride ?? request.name + if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) { + throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.") + } + if (request.baseBranch && !request.expectedRefHead) { + throw new Error('The requested provisioned-root ref identity is missing.') + } + if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) { + throw new Error("The recipe projectRoot was not created from Orca's requested ref.") + } + return gitWorktree +} + +export type ProvisionedRootMetaStore = { + getProjectHostSetups?: Store['getProjectHostSetups'] + getSettings(): Parameters[1] +} + +export function buildProvisionedRootMeta( + store: ProvisionedRootMetaStore, + repo: Repo, + args: ProvisionedRootRequest, + options: { + branchName: string + now: number + /** Omitted on the host that runs the checkout, whose own rows carry no host id. */ + hostId?: ExecutionHostId + source: NonNullable + } +): Partial { + const { branchName, now } = options + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + false, + args.nameWasGenerated === true + ) + const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( + displayNameRequest.value, + branchName, + displayNameRequest.kind, + { requestedName: args.name, sanitizedName: args.name } + ) + return { + instanceId: randomUUID(), + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), + ...(options.hostId ? { hostId: options.hostId } : {}), + ephemeralVmCheckoutMode: 'provisioned-root', + displayName: displayNameMeta.displayName ?? args.name, + ...displayNameMeta, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: options.source, + creatorProvenance: { kind: 'host' }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, store.getSettings()), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.compareBaseRef || args.baseBranch + ? { baseRef: args.compareBaseRef ?? args.baseBranch } + : {}), + ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), + ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent) + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), + ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), + ...(args.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}) + } +} diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index 3f6565382d81..f90aef7df94a 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -1,34 +1,29 @@ -import { randomUUID } from 'node:crypto' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import type { AdoptProvisionedRootArgs, CreateWorktreeResult } from '../shared/worktree/create-types' -import type { WorktreeMeta } from '../shared/worktree/meta-types' import type { AutomationWorkspaceProvenance } from '../shared/worktree/types' import { isRuntimeOwnedSshTargetId, toSshExecutionHostId } from '../shared/execution-host' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { getEphemeralVmRecipeResultCheckoutMode, getEphemeralVmRecipeResultProjectRoot } from '../shared/ephemeral-vm-recipes' import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store' import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' -import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup' -import { isTuiAgent } from '../shared/tui-agent-config' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { getSshProviderAuthority, isCurrentSshProviderAuthority } from './ssh/ssh-provider-authority' import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment' +import { mergeWorktree } from './ipc/worktree-logic' import { - getWorktreeCreationLayout, - mergeWorktree, - resolveWorktreeCreateDisplayNameMeta, - resolveWorktreeCreateDisplayNameRequest -} from './ipc/worktree-logic' + buildProvisionedRootMeta, + provisionedRootPathsEqual, + verifyProvisionedRootCheckout +} from './provisioned-root-checkout' type AdoptionArgs = AdoptProvisionedRootArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -59,7 +54,10 @@ export async function adoptProvisionedRootSshCheckout(args: { connectionId ) const projectRoot = getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult) - if (!pathsEqual(request.expectedPath, projectRoot) || !pathsEqual(repo.path, projectRoot)) { + if ( + !provisionedRootPathsEqual(request.expectedPath, projectRoot) || + !provisionedRootPathsEqual(repo.path, projectRoot) + ) { throw new Error('The recipe projectRoot does not match the imported Git checkout root.') } @@ -81,30 +79,12 @@ export async function adoptProvisionedRootSshCheckout(args: { } requireOwnedProvisionedRootRuntime(args.userDataPath, request.runtimeId, connectionId) - const matches = worktrees.filter((worktree) => pathsEqual(worktree.path, projectRoot)) - if (matches.length !== 1) { - throw new Error('The recipe projectRoot is not a unique Git checkout root.') - } - const gitWorktree = matches[0] - if (!gitWorktree.isMainWorktree) { - throw new Error('The recipe projectRoot must be the repository primary checkout.') - } - if (gitWorktree.isBare) { - throw new Error('Provisioned-root recipes cannot adopt a bare repository.') - } - if (gitWorktree.isSparse || sparseCheckoutEnabled) { - throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.') - } - const requestedBranch = request.branchNameOverride ?? request.name - if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) { - throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.") - } - if (request.baseBranch && !request.expectedRefHead) { - throw new Error('The requested provisioned-root ref identity is missing.') - } - if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) { - throw new Error("The recipe projectRoot was not created from Orca's requested ref.") - } + const gitWorktree = verifyProvisionedRootCheckout({ + worktrees, + sparseCheckoutEnabled, + projectRoot, + request + }) const worktreeId = `${repo.id}::${gitWorktree.path}` attachEphemeralVmRuntimeToWorkspace({ @@ -115,13 +95,12 @@ export async function adoptProvisionedRootSshCheckout(args: { const now = Date.now() const meta = store.setWorktreeMeta( worktreeId, - buildProvisionedRootMeta( - store, - repo, - request, - gitWorktree.branch.replace(/^refs\/heads\//, ''), - now - ) + buildProvisionedRootMeta(store, repo, request, { + branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), + now, + hostId: args.request.executionHostId, + source: 'ssh' + }) ) return { worktree: mergeWorktree(repo.id, gitWorktree, meta) } } @@ -157,77 +136,3 @@ function requireOwnedProvisionedRootRuntime( } return runtime } - -function pathsEqual(left: string, right: string): boolean { - return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) -} - -function buildProvisionedRootMeta( - store: Store, - repo: Repo, - args: AdoptionArgs, - branchName: string, - now: number -): Partial { - const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( - args.displayName, - args.displayNameKind, - args.name, - false, - args.nameWasGenerated === true - ) - const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( - displayNameRequest.value, - branchName, - displayNameRequest.kind, - { requestedName: args.name, sanitizedName: args.name } - ) - return { - instanceId: randomUUID(), - ...(store.getProjectHostSetups - ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - : {}), - hostId: args.executionHostId, - ephemeralVmCheckoutMode: 'provisioned-root', - displayName: displayNameMeta.displayName ?? args.name, - ...displayNameMeta, - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'ssh', - creatorProvenance: { kind: 'host' }, - orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, store.getSettings()), - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.compareBaseRef || args.baseBranch - ? { baseRef: args.compareBaseRef ?? args.baseBranch } - : {}), - ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), - ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent) - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), - ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), - ...(args.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}) - } -} diff --git a/src/main/rate-limits/claude-managed-usage-credential-source.test.ts b/src/main/rate-limits/claude-managed-usage-credential-source.test.ts new file mode 100644 index 000000000000..ecdc6cea2d7a --- /dev/null +++ b/src/main/rate-limits/claude-managed-usage-credential-source.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as ClaudeOAuthCredentials from './claude-oauth-credentials' + +type CredentialSourceFixture = { + source: ClaudeOAuthCredentials.ClaudeOAuthCredentialSource + readCredentials: ReturnType + fetchUsage: ReturnType + hostPreparation: ReturnType + wslPreparation: ReturnType +} + +const fixture = vi.hoisted((): CredentialSourceFixture => ({ + source: 'credentials-file', + readCredentials: vi.fn(), + fetchUsage: vi.fn(), + hostPreparation: vi.fn(), + wslPreparation: vi.fn() +})) + +vi.mock('./claude-oauth-credentials', async (importOriginal) => ({ + ...(await importOriginal()), + readClaudeOAuthCredentials: fixture.readCredentials +})) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => ({ accountUsagePreparation: fixture.hostPreparation }), + getClaudeWslProfileRouter: () => ({ accountUsagePreparation: fixture.wslPreparation }) +})) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: async (paths: readonly string[]) => [...paths] +})) +vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: fixture.fetchUsage })) + +import { fetchInactiveClaudeAccountUsage } from './claude-managed-account-usage' +import { OAuthUsageError } from './claude-oauth-usage-error' + +const configDir = join(tmpdir(), 'synthetic-claude-profile-A') +const now = 1800000000000 + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Date, 'now').mockReturnValue(now) + fixture.readCredentials.mockImplementation(async () => ({ + token: 'synthetic-token', + hasRefreshableCredentials: false, + source: fixture.source + })) + fixture.fetchUsage.mockRejectedValue(new OAuthUsageError('Synthetic wait', 429, true, 3600000)) +}) + +afterEach(() => vi.restoreAllMocks()) + +it.each([ + ['host', 'scoped-keychain'], + ['host', 'credentials-file'], + ['wsl', 'credentials-file'] +] as const)('keeps %s profile 429 metadata from %s', async (runtime, source) => { + fixture.source = source + const provenance = runtime === 'wsl' ? 'wsl:SyntheticDistro:profile:A' : 'profile:A' + const preparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + provenance + } + fixture.hostPreparation.mockReturnValue(preparation) + fixture.wslPreparation.mockResolvedValue(preparation) + const result = await fetchInactiveClaudeAccountUsage({ + id: 'A', + managedAuthRuntime: runtime, + wslDistro: runtime === 'wsl' ? 'SyntheticDistro' : null + }) + + expect(result.usageMetadata).toMatchObject({ + credentialSource: source, + authProvenance: provenance, + failureKind: 'rate-limited', + retryAtMs: now + 3600000 + }) + expect(fixture.readCredentials).toHaveBeenCalledWith({ + credentialsFileConfigDir: configDir, + keychainConfigDir: configDir, + unsuffixedKeychainFallback: false + }) + expect(fixture.fetchUsage).toHaveBeenCalledWith('synthetic-token', undefined) + if (runtime === 'wsl') { + expect(fixture.wslPreparation).toHaveBeenCalledWith('SyntheticDistro', 'A') + expect(fixture.hostPreparation).not.toHaveBeenCalled() + } else { + expect(fixture.hostPreparation).toHaveBeenCalledWith('A') + expect(fixture.wslPreparation).not.toHaveBeenCalled() + } +}) diff --git a/src/main/rate-limits/service-inactive-claude-cooldown.test.ts b/src/main/rate-limits/service-inactive-claude-cooldown.test.ts new file mode 100644 index 000000000000..1e8c44963e58 --- /dev/null +++ b/src/main/rate-limits/service-inactive-claude-cooldown.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createServer, type ServerResponse } from 'node:http' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +type CooldownFixture = { + userData: string + origin: string + now: number + status: number + retryAfter: string | null + statusB: number + retryAfterB: string | null + hold: boolean + pending: ServerResponse | null + arrived: () => void + requests: string[] +} + +const fixture = vi.hoisted((): CooldownFixture => ({ + userData: '', + origin: '', + now: 1800000000000, + status: 200, + retryAfter: '3600', + statusB: 200, + retryAfterB: '7200', + hold: false, + pending: null, + arrived: () => {}, + requests: [] +})) + +vi.mock('electron', () => ({ + app: { getPath: () => fixture.userData }, + session: { defaultSession: {} }, + net: { + fetch: (url: string, options: RequestInit) => { + if (url !== 'https://api.anthropic.com/api/oauth/usage') { + throw new Error('Unexpected endpoint in local usage fixture') + } + return globalThis.fetch(fixture.origin, options) + } + } +})) + +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn(async () => {}) +})) +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn(async () => { + throw new Error('Usage fixture must not launch Claude') + }) +})) +vi.mock('../claude-accounts/keychain', () => ({ + readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir: string) => { + const { readFile } = await import('node:fs/promises') + const { join } = await import('node:path') + if (!configDir.startsWith(fixture.userData)) { + throw new Error('Usage fixture must read its own account folder') + } + return readFile(join(configDir, '.credentials.json'), 'utf8') + }) +})) +vi.mock('./grok-auth', () => ({ readGrokAuthSession: () => ({ status: 'missing' }) })) +vi.mock('./cursor-auth', () => ({ readCursorAuthSession: async () => ({ status: 'missing' }) })) +vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: () => false })) +vi.mock('../minimax/minimax-api-key-store', () => ({ hasMiniMaxApiKey: () => false })) +vi.mock('../zcode/zcode-plan-api-key-store', () => ({ hasZcodePlanApiKey: () => false })) +vi.mock('./codex-fetcher', () => ({ + fetchCodexRateLimits: async () => ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }), + consumeCodexRateLimitResetCredit: vi.fn() +})) +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: async () => ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: async () => ({ + provider: 'kimi', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: async () => ({ + provider: 'opencode-go', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./minimax/minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: async () => ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: async () => ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: async () => ({ + provider: 'cursor', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: async () => ({ + provider: 'zcode', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: async () => ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) + +import { RateLimitService } from './service' +import { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router' +import { installClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' + +let activeId = 'A' +let inactiveIds = ['A', 'B'] +let service: RateLimitService +let restoreClock: () => void +let router: ClaudeProfileRouter + +function account(id: string) { + return { id } +} + +function preparation(id: string) { + return router.accountUsagePreparation(id) +} + +function reply(response: ServerResponse, status: number, retryAfter = fixture.retryAfter) { + response.statusCode = status + response.setHeader('Content-Type', 'application/json') + if (status === 429 && retryAfter) { + response.setHeader('Retry-After', retryAfter) + } + response.end( + JSON.stringify( + status === 200 + ? { + five_hour: { utilization: 21, resets_at: '2027-01-16T14:00:00Z' }, + seven_day: { utilization: 11, resets_at: '2027-01-20T14:00:00Z' } + } + : { error: { message: 'Synthetic usage failure' } } + ) + ) +} + +const server = createServer((request, response) => { + const id = + request.headers.authorization === 'Bearer synthetic-A' + ? 'A' + : request.headers.authorization === 'Bearer synthetic-B' + ? 'B' + : null + if (!id) { + response.writeHead(403).end() + return + } + fixture.requests.push(id) + if (id === 'A' && fixture.hold) { + fixture.pending = response + fixture.arrived() + return + } + reply( + response, + id === 'A' ? fixture.status : fixture.statusB, + id === 'A' ? fixture.retryAfter : fixture.retryAfterB + ) +}) + +function count(id: string) { + return fixture.requests.filter((requestId) => requestId === id).length +} + +function cached(id: string) { + return service.getState().inactiveClaudeAccounts.find((row) => row.accountId === id)?.rateLimits +} + +beforeEach(async () => { + fixture.userData = mkdtempSync(join(tmpdir(), 'orca-inactive-claude-429-')) + fixture.now = 1800000000000 + fixture.status = 200 + fixture.retryAfter = '3600' + fixture.statusB = 200 + fixture.retryAfterB = '7200' + fixture.hold = false + fixture.pending = null + fixture.requests = [] + activeId = 'A' + inactiveIds = ['A', 'B'] + for (const id of inactiveIds) { + const path = join(fixture.userData, 'claude-profiles', id, 'home') + mkdirSync(path, { recursive: true }) + writeFileSync( + join(path, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { accessToken: `synthetic-${id}`, expiresAt: 1900000000000 } + }) + ) + } + router = new ClaudeProfileRouter({ + dataRoot: fixture.userData, + userHome: join(fixture.userData, 'user-home'), + env: { CLAUDE_CONFIG_DIR: join(fixture.userData, 'system-default') }, + getSettings: () => ({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: activeId, + activeClaudeManagedAccountIdsByRuntime: { host: activeId, wsl: {} }, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + }), + runSetup: async () => { + throw new Error('Usage fixture must not set up an account') + } + }) + installClaudeProfileRouter(router) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected local listener') + } + fixture.origin = `http://127.0.0.1:${address.port}` + const clock = vi.spyOn(Date, 'now').mockImplementation(() => fixture.now) + restoreClock = () => clock.mockRestore() + service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => preparation(activeId)) + service.setInactiveClaudeAccountsResolver(() => inactiveIds.map(account)) +}) + +afterEach(async () => { + service?.stop() + installClaudeProfileRouter(undefined) + restoreClock?.() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(fixture.userData, { recursive: true, force: true }) +}) + +it.each(['seconds', 'HTTP-date'] as const)( + 'keeps A last-good usage and refreshes healthy B until the exact %s deadline', + async (format) => { + await service.fetchInactiveClaudeAccountsOnOpen() + const originalAt = cached('A')?.updatedAt + fixture.now += 61000 + fixture.status = 429 + fixture.retryAfter = + format === 'seconds' ? '3600' : new Date(fixture.now + 3600000).toUTCString() + await service.fetchInactiveClaudeAccountsOnOpen() + const retryAt = fixture.now + 3600000 + expect(cached('A')).toMatchObject({ + status: 'error', + updatedAt: originalAt, + session: { usedPercent: 21 }, + usageMetadata: { + failureKind: 'rate-limited', + retryAtMs: retryAt, + authProvenance: 'profile:A', + credentialSource: process.platform === 'darwin' ? 'scoped-keychain' : 'credentials-file' + } + }) + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(2) + expect(count('B')).toBe(3) + expect(cached('A')?.updatedAt).toBe(originalAt) + expect(service.getState().inactiveClaudeAccounts.every((row) => !row.isFetching)).toBe(true) + fixture.now = retryAt + fixture.status = 200 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(3) + expect(count('B')).toBe(4) + expect(cached('A')).toMatchObject({ status: 'ok', updatedAt: retryAt }) + } +) + +it('keeps two server waits independent when all inactive accounts are blocked', async () => { + fixture.status = 429 + fixture.statusB = 429 + await service.fetchInactiveClaudeAccountsOnOpen() + const retryAtA = fixture.now + 3600000 + const retryAtB = fixture.now + 7200000 + expect(cached('A')?.usageMetadata?.retryAtMs).toBe(retryAtA) + expect(cached('B')?.usageMetadata?.retryAtMs).toBe(retryAtB) + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(fixture.requests).toEqual(['A', 'B']) + expect(service.getState().inactiveClaudeAccounts.every((row) => !row.isFetching)).toBe(true) + fixture.now = retryAtA + fixture.status = 200 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(fixture.requests).toEqual(['A', 'B', 'A']) + expect(cached('A')?.status).toBe('ok') + expect(cached('B')?.usageMetadata?.retryAtMs).toBe(retryAtB) + fixture.now = retryAtB + fixture.statusB = 200 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(fixture.requests).toEqual(['A', 'B', 'A', 'A', 'B']) + expect(cached('B')?.status).toBe('ok') +}) + +it('honors a deadline seeded from live active usage and preserves forced account-change retries', async () => { + inactiveIds = ['B'] + await service.refreshClaudeForTarget() + fixture.now += 61000 + fixture.status = 429 + await service.refreshClaudeForTarget() + service.ingestLiveClaudeRateLimits({ + configDir: preparation('A').configDir, + fiveHour: { utilization: 23, resets_at: '2027-01-16T14:00:00Z' }, + sevenDay: null + }) + activeId = 'B' + inactiveIds = ['A'] + await service.refreshForClaudeAccountChange('A') + expect(cached('A')).toMatchObject({ + status: 'ok', + usageMetadata: { source: 'live-session', retryAtMs: fixture.now + 3600000 } + }) + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(2) + expect(count('B')).toBe(1) + expect(service.getState().inactiveClaudeAccounts[0]?.isFetching).toBe(false) + activeId = 'A' + inactiveIds = ['B'] + await service.refreshForClaudeAccountChange('B') + await service.refresh() + expect(count('A')).toBe(4) +}) + +it.each([401, 503])( + 'preserves current profile failure metadata without a wait for HTTP %s', + async (status) => { + await service.fetchInactiveClaudeAccountsOnOpen() + const originalAt = cached('A')?.updatedAt + fixture.now += 61000 + fixture.status = status + await service.fetchInactiveClaudeAccountsOnOpen() + expect(cached('A')).toMatchObject({ + status: 'error', + updatedAt: originalAt, + session: { usedPercent: 21 }, + usageMetadata: { failureKind: status === 401 ? 'stale-token' : 'server' } + }) + expect(cached('A')?.usageMetadata?.retryAtMs).toBeUndefined() + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(3) + } +) + +it.each([null, '0', '-5', 'soon'])( + 'does not invent a wait for Retry-After %s', + async (retryAfter) => { + fixture.status = 429 + fixture.retryAfter = retryAfter + await service.fetchInactiveClaudeAccountsOnOpen() + expect(cached('A')).toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'rate-limited' } + }) + expect(cached('A')?.usageMetadata?.retryAtMs).toBeUndefined() + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(2) + } +) + +it.each(['remove', 'stop'])('discards a late 429 after account %s', async (action) => { + fixture.hold = true + const arrived = new Promise((resolve) => { + fixture.arrived = resolve + }) + const pending = service.fetchInactiveClaudeAccountsOnOpen() + await arrived + if (action === 'stop') { + service.stop() + } else { + inactiveIds = ['B'] + service.evictInactiveClaudeCache('A') + } + if (!fixture.pending) { + throw new Error('Expected pending local response') + } + reply(fixture.pending, 429) + await pending + expect(cached('A')).toBeUndefined() + expect(service.getState().inactiveClaudeAccounts.every((row) => !row.isFetching)).toBe(true) +}) diff --git a/src/main/rate-limits/service-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts index 7aa9e911c602..1a775d67ccaf 100644 --- a/src/main/rate-limits/service-zcode-usage.test.ts +++ b/src/main/rate-limits/service-zcode-usage.test.ts @@ -1,3 +1,7 @@ +import { cpSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readZcodeUsageCredentials } from './zcode-usage-credentials' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' @@ -186,4 +190,37 @@ describe('RateLimitService zcode plan credentials', () => { expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(2) expect(state.zcode?.session?.usedPercent).toBe(10) }) + it('rejects an old CLI account result after sibling-provider waits', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-zcode-service-v2-')) + const fixtures = join(import.meta.dirname, '..', 'zcode', 'fixtures', 'v2-upstream') + vi.stubEnv('ZCODE_DATA_BASE_DIR', home) + vi.stubEnv('ZCODE_CREDENTIAL_SECRET', 'synthetic-secret') + try { + cpSync(join(fixtures, 'zai'), join(home, '.zcode', 'v2'), { recursive: true }) + const selected = readZcodeUsageCredentials() + if (selected.status !== 'ok') { + throw new Error('Synthetic fixture could not be read') + } + const claude = deferred() + vi.mocked(fetchClaudeRateLimits).mockImplementationOnce(() => claude.promise) + vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce({ + ...okProvider('zcode', 44, Date.now()), + usageMetadata: { + authProvenance: selected.credentials.authProvenance, + credentialSource: selected.source + } + }) + const service = new RateLimitService() + const refreshing = service.refresh() + await vi.waitFor(() => expect(fetchZcodeRateLimits).toHaveBeenCalled()) + cpSync(join(fixtures, 'zai-other'), join(home, '.zcode', 'v2'), { recursive: true }) + claude.resolve(okProvider('claude', 7)) + await refreshing + expect(service.getState().zcode?.status).toBe('unavailable') + expect(service.getState().zcode?.session).toBeNull() + } finally { + vi.unstubAllEnvs() + rmSync(home, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 2731f882be5a..7181b0ddf9cc 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,4 +1,5 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' +import { isZcodeUsageCredentialCurrent } from '../zcode-usage-credentials' import { settleSiblingProviderResult } from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' @@ -202,7 +203,17 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ } const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) - const zcode = settleSiblingProviderResult('zcode', zcodeSettled) + const settledZcode = settleSiblingProviderResult('zcode', zcodeSettled) + const zcode = isZcodeUsageCredentialCurrent(settledZcode) + ? settledZcode + : { + ...settledZcode, + status: 'unavailable' as const, + session: null, + weekly: null, + monthly: null, + error: 'ZCode Coding Plan account changed during refresh' + } const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled) // Why: the stale policy keeps a recent snapshot through a failed refresh, but diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts index e0d388639c86..70f06c093f7f 100644 --- a/src/main/rate-limits/service/service-inactive-accounts.ts +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -13,7 +13,9 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP if (this.inactiveClaudeFetching.size > 0) { return } - const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + const accounts = (this.inactiveClaudeAccountsResolver?.() ?? []).filter( + (account) => !this.isRetryAfterActive(this.inactiveClaudeCache.get(account.id) ?? null) + ) if (accounts.length === 0) { return } diff --git a/src/main/rate-limits/zcode-usage-credentials.ts b/src/main/rate-limits/zcode-usage-credentials.ts new file mode 100644 index 000000000000..d2aa3669e90c --- /dev/null +++ b/src/main/rate-limits/zcode-usage-credentials.ts @@ -0,0 +1,142 @@ +import { createHmac, randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + readZcodeV2PlanCredential, + localZcodeCredentialHost, + type ZcodeCredentialHost +} from '../zcode/zcode-v2-plan-credentials' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' + +const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) +const CREDENTIAL_IDENTITY_KEY = randomBytes(32) +export const ZCODE_PLAN_CREDENTIAL_SOURCE = 'orca-plan' +export type ZcodePlanCredential = { apiKey: string; baseUrl: string } +export type ZcodeUsageCredentials = { apiKey: string; quotaUrl: string; authProvenance: string } +export type ZcodeUsageCredentialOptions = { + configPath?: string + credentialHost?: ZcodeCredentialHost +} +export type ZcodeUsageCredentialRead = + | { status: 'unavailable' | 'error' } + | { status: 'ok'; credentials: ZcodeUsageCredentials; source: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null +} + +function readMainProvider(model: unknown): string | null { + const name = typeof model === 'string' ? model : readRecord(model)?.main + if (typeof name !== 'string') { + return null + } + const delimiter = name.indexOf('/') + return delimiter > 0 && delimiter < name.length - 1 ? name.slice(0, delimiter) : null +} +function readCredentials(configPath: string): ZcodeUsageCredentials | null { + let config: Record | null + try { + config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) + } catch { + return null + } + + if (!config) { + return null + } + // A quota from another configured account must never appear as the selected model's quota. + const mainProvider = readMainProvider(config.model) + if (!mainProvider) { + return null + } + const options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) + const apiKey = options?.apiKey + const baseURL = options?.baseURL + if ( + typeof apiKey !== 'string' || + !apiKey.trim() || + /[\r\n]/.test(apiKey) || + typeof baseURL !== 'string' + ) { + return null + } + return resolveUsageCredentials(apiKey, baseURL, mainProvider) +} + +export function resolveUsageCredentials( + key: string, + baseUrl: string, + identity: string +): ZcodeUsageCredentials | null { + const apiKey = key.trim() + if (!apiKey || /[\r\n]/.test(apiKey)) { + return null + } + try { + const parsed = new URL(baseUrl) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + return null + } + return { + apiKey, + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, + authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) + .update(JSON.stringify([identity, parsed.origin, apiKey])) + .digest('hex') + } + } catch { + return null + } +} + +export function readZcodeUsageCredentials( + options: ZcodeUsageCredentialOptions = {} +): ZcodeUsageCredentialRead { + const host = options.credentialHost ?? localZcodeCredentialHost() + const configPath = options.configPath ?? join(host.home, '.zcode', 'cli', 'config.json') + // An explicit legacy fixture path never probes the developer's credential store. + const v2 = + options.configPath && !options.credentialHost + ? { status: 'absent' as const } + : readZcodeV2PlanCredential(host) + if (v2.status === 'error') { + return { status: 'error' } + } + if (v2.status === 'unavailable') { + return { status: 'unavailable' } + } + const credentials = + v2.status === 'ok' + ? resolveUsageCredentials(v2.apiKey, v2.baseUrl, v2.identity) + : readCredentials(configPath) + return credentials + ? { status: 'ok', credentials, source: v2.status === 'ok' ? v2.configPath : configPath } + : { status: 'unavailable' } +} + +export function hasZcodeCliPlanCredentials(configPath?: string): boolean { + return readZcodeUsageCredentials({ configPath }).status === 'ok' +} + +export function isZcodeUsageCredentialCurrent(usage: ProviderRateLimits): boolean { + if ( + !usage.usageMetadata?.authProvenance || + !usage.usageMetadata.credentialSource || + usage.usageMetadata.credentialSource === ZCODE_PLAN_CREDENTIAL_SOURCE + ) { + return true + } + const current = readZcodeUsageCredentials() + return ( + current.status === 'ok' && + current.credentials.authProvenance === usage.usageMetadata.authProvenance + ) +} diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts index 2eba29a4f98a..a8b8a4d00ae8 100644 --- a/src/main/rate-limits/zcode-usage-fetcher.ts +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -1,13 +1,20 @@ -import { createHmac, randomBytes } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { + readZcodeUsageCredentials, + resolveUsageCredentials, + ZCODE_PLAN_CREDENTIAL_SOURCE, + type ZcodePlanCredential, + type ZcodeUsageCredentials, + type ZcodeUsageCredentialOptions +} from './zcode-usage-credentials' +export { + hasZcodeCliPlanCredentials, + ZCODE_PLAN_CREDENTIAL_SOURCE, + type ZcodePlanCredential +} from './zcode-usage-credentials' import { cancelUnreadResponseBody } from '../lib/unread-response-body' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' const API_TIMEOUT_MS = 15_000 -const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) -const CREDENTIAL_IDENTITY_KEY = randomBytes(32) type QuotaLimit = { type?: unknown @@ -20,20 +27,6 @@ type QuotaLimit = { nextResetTime?: unknown } -type ZcodeUsageCredentials = { - apiKey: string - quotaUrl: string - authProvenance: string -} - -/** A GLM Coding Plan key saved through Orca's AI Provider Accounts; takes priority over the ZCode CLI config. */ -export type ZcodePlanCredential = { - apiKey: string - baseUrl: string -} - -export const ZCODE_PLAN_CREDENTIAL_SOURCE = 'orca-plan' - // Why readers and not casts: both JSON sources are outside our control — a user-edited // config file and a remote response — so their shape is a guess until something checks it. function isRecord(value: unknown): value is Record { @@ -44,15 +37,6 @@ function readRecord(value: unknown): Record | null { return isRecord(value) ? value : null } -function readMainProvider(model: unknown): string | null { - const name = typeof model === 'string' ? model : readRecord(model)?.main - if (typeof name !== 'string') { - return null - } - const delimiter = name.indexOf('/') - return delimiter > 0 && delimiter < name.length - 1 ? name.slice(0, delimiter) : null -} - function unavailable(error: string): ProviderRateLimits { return { provider: 'zcode', @@ -87,72 +71,6 @@ function redactCredential(error: string, apiKey: string): string { return error.replaceAll(apiKey, '[redacted]') } -function readCredentials(configPath: string): ZcodeUsageCredentials | null { - let config: Record | null - try { - config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) - } catch { - return null - } - - if (!config) { - return null - } - // A quota from another configured account must never appear as the selected model's quota. - const mainProvider = readMainProvider(config.model) - if (!mainProvider) { - return null - } - const options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) - const apiKey = options?.apiKey - const baseURL = options?.baseURL - if ( - typeof apiKey !== 'string' || - !apiKey.trim() || - /[\r\n]/.test(apiKey) || - typeof baseURL !== 'string' - ) { - return null - } - return resolveUsageCredentials(apiKey, baseURL, mainProvider) -} - -function resolveUsageCredentials( - key: string, - baseUrl: string, - identity: string -): ZcodeUsageCredentials | null { - const apiKey = key.trim() - if (!apiKey || /[\r\n]/.test(apiKey)) { - return null - } - try { - const parsed = new URL(baseUrl) - if ( - parsed.protocol !== 'https:' || - !SUPPORTED_HOSTS.has(parsed.hostname) || - (parsed.port !== '' && parsed.port !== '443') - ) { - return null - } - return { - apiKey, - quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, - authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) - .update(JSON.stringify([identity, parsed.origin, apiKey])) - .digest('hex') - } - } catch { - return null - } -} - -export function hasZcodeCliPlanCredentials( - configPath = join(homedir(), '.zcode', 'cli', 'config.json') -): boolean { - return readCredentials(configPath) !== null -} - function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } @@ -210,35 +128,49 @@ function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { } export async function fetchZcodeRateLimits( - options: { - configPath?: string + options: ZcodeUsageCredentialOptions & { planCredential?: ZcodePlanCredential | null signal?: AbortSignal } = {} ): Promise { - const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json') - const planCredentials = options.planCredential - ? resolveUsageCredentials( - options.planCredential.apiKey, - options.planCredential.baseUrl, - ZCODE_PLAN_CREDENTIAL_SOURCE - ) + const plan = options.planCredential + const saved = plan + ? resolveUsageCredentials(plan.apiKey, plan.baseUrl, ZCODE_PLAN_CREDENTIAL_SOURCE) : null - if (!planCredentials && options.planCredential) { - // Why: a saved-but-unusable key must surface as its own error; silently - // falling back to the CLI config would show a different account's quota. + if (plan && !saved) { return failed('The saved GLM Coding Plan API key is unusable', 'parse', '') } - const credentials = planCredentials ?? readCredentials(configPath) + const cli = saved ? null : readZcodeUsageCredentials(options) + if (cli?.status === 'error') { + return failed('Could not read the selected ZCode CLI Coding Plan credential', 'parse', '') + } + const credentials = saved ?? (cli?.status === 'ok' ? cli.credentials : null) if (!credentials) { return unavailable('ZCode Coding Plan credentials are not configured') } - const credentialSource = planCredentials ? ZCODE_PLAN_CREDENTIAL_SOURCE : configPath + const source = saved ? ZCODE_PLAN_CREDENTIAL_SOURCE : cli?.status === 'ok' ? cli.source : '' + const result = await fetchUsage(credentials, source, options.signal) + if (!saved) { + const current = readZcodeUsageCredentials(options) + if ( + current.status !== 'ok' || + current.credentials.authProvenance !== credentials.authProvenance + ) { + return unavailable('ZCode Coding Plan account changed during refresh') + } + } + return { ...result, usageMetadata: { ...result.usageMetadata, credentialSource: source } } +} +async function fetchUsage( + credentials: ZcodeUsageCredentials, + credentialSource: string, + callerSignal?: AbortSignal +): Promise { let response: Response try { - const signal = options.signal - ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + const signal = callerSignal + ? AbortSignal.any([callerSignal, AbortSignal.timeout(API_TIMEOUT_MS)]) : AbortSignal.timeout(API_TIMEOUT_MS) response = await fetch(credentials.quotaUrl, { method: 'GET', diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 845ebdfc8a2b..133bc8eca9de 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -1,19 +1,26 @@ -/** - * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. - * - * Five detector attempts were tuned against a five-line screen someone typed from memory, and - * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what - * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name - * themselves as skipped until someone can reach them. - * - * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained - * tail ends on the error block. That asserts what it does, not what it should. - */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { + finalReplayFrame, + readRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + describeScreenRuledAgentTranscripts, + readsIdleComposer +} from './screen-ruled-agent-transcript-suite' +import { + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isQuietReadyScreenBody +} from './terminal-wait-detection' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -22,194 +29,341 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const FIXTURE_DIR = join(__dirname, '__fixtures__') -// Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to -// hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. -const READY_TIMEOUT_MS = 2_000 -const REFUSAL_TIMEOUT_MS = 600 -/** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ -const ANTIGRAVITY_COMMAND = 'agy' -// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw -// control byte in source, which is unreadable and survives badly in diffs. -const ESC = String.fromCharCode(27) - -type TranscriptCase = { - /** Fixture basename; `.txt` under `__fixtures__/`. */ - name: string - /** Capture group identifier. */ - capture: string - what: string - /** What a correct detector must answer. Not what the shipped one answers. */ - expectReady: boolean +describe('Antigravity raw captured readiness', () => { /** - * Set where the shipped detector contradicts the transcript. The case then runs inverted, so - * CI pins the defect instead of going permanently red — and flips to failing the moment - * someone fixes it, which is exactly when these expectations need re-reading. + * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. + * + * Five detector attempts were tuned against a five-line screen someone typed from memory, and + * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what + * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name + * themselves as skipped until someone can reach them. + * + * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained + * tail ends on the error block. That asserts what it does, not what it should. */ - knownDefect?: string -} - -const TRANSCRIPTS: readonly TranscriptCase[] = [ - { - name: 'antigravity-ready-api-key-gemini-model', - capture: 'B', - what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', - expectReady: true - }, - { - name: 'antigravity-ready-account-info-hidden', - capture: 'B', - what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', - expectReady: true - }, - { - name: 'antigravity-dialog-trust-workspace', - capture: 'C', - what: 'workspace trust dialog owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-model-picker', - capture: 'C', - what: 'model picker owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-command-palette', - capture: 'C', - what: 'slash-command palette owning the screen', - expectReady: false - }, - { - name: 'antigravity-busy-mid-turn', - capture: 'E', - what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', - expectReady: false - }, - { - // Expected ready because the turn is over and the composer is back on screen. The captured - // turn ends in a backend error, which is the only ending this account's key can produce. - name: 'antigravity-busy-turn-ended', - capture: 'E', - what: 'the turn has ended and the composer has returned, process still alive', - expectReady: true, - knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' - }, - { - name: 'antigravity-dialog-dismissed', - capture: 'D', - what: 'the screen immediately after the model picker is dismissed', - expectReady: true - }, - // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and - // reaching the rest would mean signing the operator out or deleting their config. - { - name: 'antigravity-ready-business-non-gemini', - capture: 'A', - what: 'ready screen, Business account, non-Gemini model', - expectReady: true - }, - { - name: 'antigravity-dialog-sign-in', - capture: 'C', - what: 'sign-in dialog owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-theme-picker', - capture: 'C', - what: 'theme picker owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-privacy-notice', - capture: 'C', - what: 'privacy notice owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-update-banner', - capture: 'C', - what: 'update banner owning the screen', - expectReady: false + + const FIXTURE_DIR = join(__dirname, '__fixtures__') + // Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to + // hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. + const READY_TIMEOUT_MS = 2_000 + const REFUSAL_TIMEOUT_MS = 600 + /** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ + const ANTIGRAVITY_COMMAND = 'agy' + // String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw + // control byte in source, which is unreadable and survives badly in diffs. + const ESC = String.fromCharCode(27) + + type TranscriptCase = { + /** Fixture basename; `.txt` under `__fixtures__/`. */ + name: string + /** Capture group identifier. */ + capture: string + what: string + /** What a correct detector must answer. Not what the shipped one answers. */ + expectReady: boolean + /** + * Set where the shipped detector contradicts the transcript. The case then runs inverted, so + * CI pins the defect instead of going permanently red — and flips to failing the moment + * someone fixes it, which is exactly when these expectations need re-reading. + */ + knownDefect?: string } -] - -function fixturePath(name: string): string { - return join(FIXTURE_DIR, `${name}.txt`) -} - -/** - * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it - * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever - * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so - * they are both `ready: false` here. This is the shape `worker-start` sees. - */ -async function readinessVerdict( - transcript: string, - timeoutMs: number -): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { - const { runtime, handle } = await createTranscriptPane({ - // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw - // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. - paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, - foregroundProcess: ANTIGRAVITY_COMMAND, - data: transcript - }) - try { - const result = (await runtime.waitForTerminal(handle, { - condition: 'tui-idle', - timeoutMs - })) as { satisfied?: boolean; blockedReason?: unknown } - return { - ready: result.satisfied === true, - blockedReason: result.blockedReason ?? null, - outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + + const TRANSCRIPTS: readonly TranscriptCase[] = [ + { + name: 'antigravity-ready-api-key-gemini-model', + capture: 'B', + what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', + expectReady: true + }, + { + name: 'antigravity-ready-account-info-hidden', + capture: 'B', + what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', + expectReady: true + }, + { + name: 'antigravity-dialog-trust-workspace', + capture: 'C', + what: 'workspace trust dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-model-picker', + capture: 'C', + what: 'model picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-command-palette', + capture: 'C', + what: 'slash-command palette owning the screen', + expectReady: false + }, + { + name: 'antigravity-busy-mid-turn', + capture: 'E', + what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', + expectReady: false + }, + { + // Expected ready because the turn is over and the composer is back on screen. The captured + // turn ends in a backend error, which is the only ending this account's key can produce. + name: 'antigravity-busy-turn-ended', + capture: 'E', + what: 'the turn has ended and the composer has returned, process still alive', + expectReady: true, + knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' + }, + { + name: 'antigravity-dialog-dismissed', + capture: 'D', + what: 'the screen immediately after the model picker is dismissed', + expectReady: true + }, + // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and + // reaching the rest would mean signing the operator out or deleting their config. + { + name: 'antigravity-ready-business-non-gemini', + capture: 'A', + what: 'ready screen, Business account, non-Gemini model', + expectReady: true + }, + { + name: 'antigravity-dialog-sign-in', + capture: 'C', + what: 'sign-in dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-theme-picker', + capture: 'C', + what: 'theme picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-privacy-notice', + capture: 'C', + what: 'privacy notice owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-update-banner', + capture: 'C', + what: 'update banner owning the screen', + expectReady: false } - } catch (error) { - return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + ] + + function fixturePath(name: string): string { + return join(FIXTURE_DIR, `${name}.txt`) } -} - -describe('Antigravity readiness, decided by captured transcripts', () => { - for (const transcript of TRANSCRIPTS) { - const path = fixturePath(transcript.name) - const captured = existsSync(path) - const label = `capture ${transcript.capture}: ${transcript.what}` - - // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently - // red; fixing the detector flips this case to failing, which is when these expectations - // need re-reading. The correct answer stays in `expectReady` and in the test's name. - const shipped = - transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady - const verdictName = - transcript.knownDefect === undefined - ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` - : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` - - it.skipIf(!captured)( - verdictName, - async () => { - // A refusal only has to hold for one poll; a ready verdict has to survive the settle - // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. - const verdict = await readinessVerdict( - readFileSync(path, 'utf8'), - transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS - ) - // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca - // does not call the pane ready and type a prompt into a dialog that owns the screen. - expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ - ready: shipped - }) - }, - READY_TIMEOUT_MS + 10_000 - ) - it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { - const text = readFileSync(path, 'utf8') - // Why: a transcript with no escape bytes went through a terminal's renderer and a - // human's clipboard. It cannot answer what the caret or chrome looked like. - expect(text).toContain(ESC) + /** + * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it + * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever + * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so + * they are both `ready: false` here. This is the shape `worker-start` sees. + */ + async function readinessVerdict( + transcript: string, + timeoutMs: number + ): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { + const { runtime, handle } = await createTranscriptPane({ + // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw + // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. + paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, + foregroundProcess: ANTIGRAVITY_COMMAND, + data: transcript }) + try { + const result = (await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs + })) as { satisfied?: boolean; blockedReason?: unknown } + return { + ready: result.satisfied === true, + blockedReason: result.blockedReason ?? null, + outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + } + } catch (error) { + return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + } } + + describe('Antigravity readiness, decided by captured transcripts', () => { + for (const transcript of TRANSCRIPTS) { + const path = fixturePath(transcript.name) + const captured = existsSync(path) + const label = `capture ${transcript.capture}: ${transcript.what}` + + // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently + // red; fixing the detector flips this case to failing, which is when these expectations + // need re-reading. The correct answer stays in `expectReady` and in the test's name. + const shipped = + transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady + const verdictName = + transcript.knownDefect === undefined + ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` + : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` + + it.skipIf(!captured)( + verdictName, + async () => { + // A refusal only has to hold for one poll; a ready verdict has to survive the settle + // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. + const verdict = await readinessVerdict( + readFileSync(path, 'utf8'), + transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS + ) + // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca + // does not call the pane ready and type a prompt into a dialog that owns the screen. + expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ + ready: shipped + }) + }, + READY_TIMEOUT_MS + 10_000 + ) + + it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { + const text = readFileSync(path, 'utf8') + // Why: a transcript with no escape bytes went through a terminal's renderer and a + // human's clipboard. It cannot answer what the caret or chrome looked like. + expect(text).toContain(ESC) + }) + } + }) +}) + +describe('Antigravity screen ruled readiness', () => { + // agy 1.2.14 on macOS, AGY_CLI_HIDE_ACCOUNT_INFO=1 (see each .meta.json); STA-8741. + const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) + const READY = [ + at120x40('antigravity-1-2-14-ready', 'settled startup'), + at120x40('antigravity-1-2-14-ready-accept-edits', '--mode accept-edits'), + at120x40('antigravity-1-2-14-ready-plan', '--mode plan'), + { name: 'antigravity-1-2-14-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, + at120x40('antigravity-1-2-14-picker-dismissed', '/model closed with Esc'), + at120x40('antigravity-1-2-14-turn-ended', 'a turn has ended') + ] + const NOT_READY = [ + at120x40('antigravity-1-2-14-model-picker', '/model picker open'), + at120x40('antigravity-1-2-14-command-palette', 'slash palette open'), + at120x40('antigravity-1-2-14-busy-thinking', 'spinner before the answer'), + at120x40('antigravity-1-2-14-busy-streaming', 'answer streaming'), + at120x40('antigravity-1-2-14-trust-dialog', 'workspace trust dialog'), + at120x40('antigravity-1-2-14-draft', 'unsent text in the composer') + ] + + describe('Antigravity 1.2.14 readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'antigravity', + foregroundProcess: 'agy', + ready: READY, + notReady: NOT_READY, + // Why these: the line-folded text rule reads only these ready screens. + readyWithoutScreen: [ + 'antigravity-1-2-14-ready', + 'antigravity-1-2-14-ready-80x24', + 'antigravity-1-2-14-picker-dismissed' + ] + }) + + it.each([ + 'antigravity-1-2-14-ready-accept-edits', + 'antigravity-1-2-14-ready-plan', + 'antigravity-1-2-14-turn-ended' + ])('%s: the line-folded text rule alone misses this ready screen', async (name) => { + const { waitText } = await finalReplayFrame(name, 120, 40) + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + it('does not let the text rules overrule a screen that refused', async () => { + const { waitText } = await finalReplayFrame('antigravity-1-2-14-picker-dismissed', 120, 40) + const { ruledScreenLines } = await finalReplayFrame( + 'antigravity-1-2-14-model-picker', + 120, + 40 + ) + // Presence precondition: the text alone would say ready. + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + expect(isQuietReadyScreenBody(waitText, 'antigravity', () => ruledScreenLines)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'antigravity', () => ruledScreenLines, false)).toBe( + false + ) + }) + + // Why a caret rule is not enough: agy keeps the bare composer caret painted through both. + it.each(['antigravity-1-2-14-busy-streaming', 'antigravity-1-2-14-model-picker'])( + '%s: the bare caret is still on screen', + async (name) => { + const { ruledScreenLines } = await finalReplayFrame(name, 120, 40) + expect(ruledScreenLines.some((line) => line.trim() === '>')).toBe(true) + } + ) + + // Why a clocked pane waits for quiet: the submit repaint clears the composer a moment before + // it swaps `? for shortcuts` for `esc to cancel`. + it('reads a submit repaint as ready for a moment mid-turn', async () => { + const data = readRuntimeFixture('antigravity-1-2-14-turn-ended') + let submitted = false + let answered = false + let readyMidTurn = 0 + for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { + submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) + answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') + if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) { + readyMidTurn += 1 + } + } + expect(submitted && answered).toBe(true) + expect(readyMidTurn).toBeGreaterThan(0) + }) + + // Why: a shell auto-title names the process; before the screen decided, that name-only idle + // title settled a pane whose picker carries no blocked wording. + it('does not settle an open model picker from a name-only `agy` title', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'agy', + foregroundProcess: 'agy', + launchAgent: 'antigravity', + data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) + }, 15_000) + + // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. + it('trusts a reflowed grid again once a PTY resize off it repaints the TUI', async () => { + const turnEnded = readRuntimeFixture('antigravity-1-2-14-turn-ended') + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'agy', + launchAgent: 'antigravity' as const, + data: turnEnded, + size: { cols: 100, rows: 30 } + } + const { runtime, handle } = await createTranscriptPane(options) + const resizePty = (cols: number, rows: number) => { + options.size = { cols, rows } + runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) + } + const settles = async () => + ( + await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ + satisfied: false + })) + ).satisfied + options.size = { cols: 120, rows: 40 } + runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(false) + resizePty(121, 40) + resizePty(120, 40) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, turnEnded, Date.now()) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(true) + }, 45_000) + }) }) diff --git a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts deleted file mode 100644 index 1735c102151c..000000000000 --- a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from './agent-transcript-pane-test-harness' -import { - finalReplayFrame, - readRuntimeFixture, - replayTranscript -} from './agent-transcript-replay-test-harness' -import { - describeScreenRuledAgentTranscripts, - readsIdleComposer -} from './screen-ruled-agent-transcript-suite' -import { - isKnownReadyPromptBody, - isKnownReadyPromptPreview, - isQuietReadyScreenBody -} from './terminal-wait-detection' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// agy 1.2.14 on macOS, AGY_CLI_HIDE_ACCOUNT_INFO=1 (see each .meta.json); STA-8741. -const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) -const READY = [ - at120x40('antigravity-1-2-14-ready', 'settled startup'), - at120x40('antigravity-1-2-14-ready-accept-edits', '--mode accept-edits'), - at120x40('antigravity-1-2-14-ready-plan', '--mode plan'), - { name: 'antigravity-1-2-14-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, - at120x40('antigravity-1-2-14-picker-dismissed', '/model closed with Esc'), - at120x40('antigravity-1-2-14-turn-ended', 'a turn has ended') -] -const NOT_READY = [ - at120x40('antigravity-1-2-14-model-picker', '/model picker open'), - at120x40('antigravity-1-2-14-command-palette', 'slash palette open'), - at120x40('antigravity-1-2-14-busy-thinking', 'spinner before the answer'), - at120x40('antigravity-1-2-14-busy-streaming', 'answer streaming'), - at120x40('antigravity-1-2-14-trust-dialog', 'workspace trust dialog'), - at120x40('antigravity-1-2-14-draft', 'unsent text in the composer') -] - -describe('Antigravity 1.2.14 readiness from captured bytes', () => { - describeScreenRuledAgentTranscripts({ - agent: 'antigravity', - foregroundProcess: 'agy', - ready: READY, - notReady: NOT_READY, - // Why these: the line-folded text rule reads only these ready screens. - readyWithoutScreen: [ - 'antigravity-1-2-14-ready', - 'antigravity-1-2-14-ready-80x24', - 'antigravity-1-2-14-picker-dismissed' - ] - }) - - it.each([ - 'antigravity-1-2-14-ready-accept-edits', - 'antigravity-1-2-14-ready-plan', - 'antigravity-1-2-14-turn-ended' - ])('%s: the line-folded text rule alone misses this ready screen', async (name) => { - const { waitText } = await finalReplayFrame(name, 120, 40) - expect(isKnownReadyPromptPreview(waitText)).toBe(false) - }) - - it('does not let the text rules overrule a screen that refused', async () => { - const { waitText } = await finalReplayFrame('antigravity-1-2-14-picker-dismissed', 120, 40) - const { ruledScreenLines } = await finalReplayFrame('antigravity-1-2-14-model-picker', 120, 40) - // Presence precondition: the text alone would say ready. - expect(isKnownReadyPromptPreview(waitText)).toBe(true) - expect(isQuietReadyScreenBody(waitText, 'antigravity', () => ruledScreenLines)).toBe(false) - expect(isKnownReadyPromptBody(waitText, 'antigravity', () => ruledScreenLines, false)).toBe( - false - ) - }) - - // Why a caret rule is not enough: agy keeps the bare composer caret painted through both. - it.each(['antigravity-1-2-14-busy-streaming', 'antigravity-1-2-14-model-picker'])( - '%s: the bare caret is still on screen', - async (name) => { - const { ruledScreenLines } = await finalReplayFrame(name, 120, 40) - expect(ruledScreenLines.some((line) => line.trim() === '>')).toBe(true) - } - ) - - // Why a clocked pane waits for quiet: the submit repaint clears the composer a moment before - // it swaps `? for shortcuts` for `esc to cancel`. - it('reads a submit repaint as ready for a moment mid-turn', async () => { - const data = readRuntimeFixture('antigravity-1-2-14-turn-ended') - let submitted = false - let answered = false - let readyMidTurn = 0 - for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { - submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) - answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') - if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) { - readyMidTurn += 1 - } - } - expect(submitted && answered).toBe(true) - expect(readyMidTurn).toBeGreaterThan(0) - }) - - // Why: a shell auto-title names the process; before the screen decided, that name-only idle - // title settled a pane whose picker carries no blocked wording. - it('does not settle an open model picker from a name-only `agy` title', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'agy', - foregroundProcess: 'agy', - launchAgent: 'antigravity', - data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) - }, 15_000) - - // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. - it('trusts a reflowed grid again once a PTY resize off it repaints the TUI', async () => { - const turnEnded = readRuntimeFixture('antigravity-1-2-14-turn-ended') - const options = { - paneTitle: 'Terminal', - foregroundProcess: 'agy', - launchAgent: 'antigravity' as const, - data: turnEnded, - size: { cols: 100, rows: 30 } - } - const { runtime, handle } = await createTranscriptPane(options) - const resizePty = (cols: number, rows: number) => { - options.size = { cols, rows } - runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) - } - const settles = async () => - (await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ satisfied: false }))) - .satisfied - options.size = { cols: 120, rows: 40 } - runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) - await runtime.readTerminal(handle, { screen: true }) - expect(await settles()).toBe(false) - resizePty(121, 40) - resizePty(120, 40) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, turnEnded, Date.now()) - await runtime.readTerminal(handle, { screen: true }) - expect(await settles()).toBe(true) - }, 45_000) -}) diff --git a/src/main/runtime/browser-client-download-transfers.ts b/src/main/runtime/browser-client-download-transfers.ts index fce33c3638a9..8341bacfe3f4 100644 --- a/src/main/runtime/browser-client-download-transfers.ts +++ b/src/main/runtime/browser-client-download-transfers.ts @@ -330,5 +330,5 @@ function isDestinationExistsError(error: unknown): boolean { } function sessionKey(browserPageId: string, transferId: string): string { - return `${browserPageId}${transferId}` + return `${browserPageId}\u0000${transferId}` } diff --git a/src/main/runtime/claude-task-wakeup-indexed-read.test.ts b/src/main/runtime/claude-task-wakeup-indexed-read.test.ts deleted file mode 100644 index f966c0dce514..000000000000 --- a/src/main/runtime/claude-task-wakeup-indexed-read.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from './agent-transcript-pane-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' -const ready = readFileSync( - join(import.meta.dirname, '__fixtures__/claude-ready-task-wakeup.txt'), - 'utf8' -) - -function owedRow(paneKey: string): AgentStatusIpcPayload { - const now = Date.now() - return { - paneKey, - state: 'working', - mainAgent: { state: 'done', stateStartedAt: now }, - agentType: 'claude', - prompt: '', - connectionId: null, - launchToken: 'transcript-launch', - claudeTaskWakeupPending: 'notification', - providerSession: { key: 'session_id', id: 'session-a' }, - observation: { - origin: 'hook', - authorityId: 'host-a', - incarnation: 1, - revision: 1, - observedAt: now - }, - receivedAt: now, - stateStartedAt: now - } -} - -afterEach(() => vi.useRealTimers()) - -describe('Claude readiness indexed status reads', () => { - it.each([true, false])( - 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', - async (pending) => { - const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) - const row = owedRow(PANE) - const full = vi.fn(() => [row, ...unrelated]) - const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) - const pane = await createTranscriptPane( - { - paneTitle: 'Terminal', - foregroundProcess: 'claude', - data: ready, - launchAgent: 'claude' - }, - { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } - ) - row.receivedAt = Date.now() - full.mockClear() - indexed.mockClear() - try { - const waiting = waitForTranscriptIdle(pane, 2_000) - await (pending - ? expect(waiting).rejects.toThrow(/timeout/) - : expect(waiting).resolves.toMatchObject({ satisfied: true })) - expect(indexed).toHaveBeenCalled() - expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) - expect(full).not.toHaveBeenCalled() - } finally { - pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - } - ) -}) diff --git a/src/main/runtime/cline-screen-readiness-transcripts.test.ts b/src/main/runtime/cline-screen-readiness-transcripts.test.ts index c2ff78bbd96c..de631d4638bc 100644 --- a/src/main/runtime/cline-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/cline-screen-readiness-transcripts.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + waitForTranscriptIdle, + TRANSCRIPT_PANE_PTY_ID +} from './agent-transcript-pane-test-harness' import { finalReadProjection, finalReplayFrame, @@ -11,6 +15,18 @@ import { readsIdleComposer } from './screen-ruled-agent-transcript-suite' import { RuntimeMachineName } from './runtime-machine-name' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { waitForWorkerStartComposer } from './launched-agent-composer-readiness' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + getSyntheticAgentTerminalTitle, + shouldDriveSyntheticAgentTitleFromHook +} from '../../shared/synthetic-agent-title' +import { hasExplicitIdleTitle } from './tui-idle-evidence' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,84 +35,428 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -let machineNameStartSpy: { mockRestore(): void } | undefined +describe('Cline captured screen readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) + }) + + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined + }) + + // cline 3.0.66 on macOS with an isolated config; 3.0.65 on Windows recorded for PR #23269 (see + // each .meta.json); STA-8741. + const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) + const READY = [ + at120x40('cline-3-0-66-ready', 'promo dismissed, startup composer'), + at120x40('cline-3-0-66-ready-plan', 'Plan mode composer'), + { name: 'cline-3-0-66-ready-80x24', what: 'startup composer', cols: 80, rows: 24 }, + at120x40('cline-3-0-66-turn-ended', 'a turn has ended'), + at120x40('cline-3-0-65-win32-startup', 'Windows startup composer') + ] + const NOT_READY = [ + at120x40('cline-3-0-66-promo', 'Cline Desktop promo over the composer'), + at120x40('cline-3-0-66-permission', 'tool approval prompt'), + at120x40('cline-3-0-66-slash-menu', 'slash menu open'), + at120x40('cline-3-0-66-draft', 'unsent text in the composer') + ] + const STREAMING = 'cline-3-0-66-busy-streaming' + + describe('Cline readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'cline', + foregroundProcess: 'cline', + ready: READY, + notReady: NOT_READY, + // Why all: an idle Cline is quiet, so the quiet-process lane settles it. + readyWithoutScreen: READY.map(({ name }) => name) + }) + + // Presence precondition for the restored-pane suite: the read projection blanks this placeholder. + it('reads the ended turn through a projection that blanks its placeholder', async () => { + const { lines, draft } = await finalReadProjection('cline-3-0-66-turn-ended', 120, 40) + expect(lines).toContain('❯') + expect(draft).toBe('Ask anything...') + }) + + // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. + it('paints the same empty composer while a reply streams', async () => { + const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true) + }) + + it('refuses every frame whose spinner row is still on screen', async () => { + let spinnerFrames = 0 + for await (const { ruledScreenLines } of replayTranscript( + readRuntimeFixture(STREAMING), + 120, + 40 + )) { + if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { + spinnerFrames += 1 + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false) + } + } + // Presence precondition: the thinking spinner was painted above the composer. + expect(spinnerFrames).toBeGreaterThan(0) + }) -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) + it('does not settle a streaming pane before it goes quiet', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'cline', + launchAgent: 'cline', + data: readRuntimeFixture(STREAMING), + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + // Why 2.5s: inside the 3s quiescence window, past the 2s poll. + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) + }, 15_000) + }) }) -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined +describe('Grok captured worker startup', () => { + /** + * A Grok worker start replayed through the runtime at its recorded read times. Grok draws its + * composer glyph at 0.6 s and then shimmers its logo until 9.9 s, and its only title is its bare + * name, so a wait that holds that title to quiet output answers ten seconds after the composer. + */ + + const COMPOSER_FRAME_MS = GROK_STARTUP_PTY_TRACE.find((chunk) => chunk.data?.includes('❯'))?.t + const LAST_FRAME_MS = GROK_STARTUP_PTY_TRACE.at(-1)?.t ?? 0 + + async function replayGrokWorkerStart(): Promise { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'grok', + launchAgent: 'grok', + size: { cols: 120, rows: 30 }, + data: '' + }) + vi.useFakeTimers() + const startedAt = Date.now() + let settledAt: number | null = null + void waitForWorkerStartComposer(runtime, handle, 'grok', 60_000).then( + (wait) => { + settledAt = wait.satisfied ? Date.now() - startedAt : null + }, + () => {} + ) + for (const chunk of GROK_STARTUP_PTY_TRACE) { + await vi.advanceTimersByTimeAsync(Math.max(0, startedAt + chunk.t - Date.now())) + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + chunk.data ?? 'x'.repeat(chunk.bytes ?? 0), + Date.now() + ) + } + await vi.advanceTimersByTimeAsync(5_000) + return settledAt + } + + describe('a Grok worker start', () => { + afterEach(() => vi.useRealTimers()) + + it('is ready on its composer glyph, not once its logo stops animating', async () => { + expect(COMPOSER_FRAME_MS).toBeLessThan(1_000) + const settledAt = await replayGrokWorkerStart() + expect(settledAt).not.toBeNull() + expect(settledAt).toBeGreaterThanOrEqual(COMPOSER_FRAME_MS ?? 0) + // Within a second of the glyph: main's own wait answered on the title at once (2.2-2.8 s live). + expect(settledAt).toBeLessThan((COMPOSER_FRAME_MS ?? 0) + 1_000) + expect(settledAt).toBeLessThan(LAST_FRAME_MS) + }) + }) }) -// cline 3.0.66 on macOS with an isolated config; 3.0.65 on Windows recorded for PR #23269 (see -// each .meta.json); STA-8741. -const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) -const READY = [ - at120x40('cline-3-0-66-ready', 'promo dismissed, startup composer'), - at120x40('cline-3-0-66-ready-plan', 'Plan mode composer'), - { name: 'cline-3-0-66-ready-80x24', what: 'startup composer', cols: 80, rows: 24 }, - at120x40('cline-3-0-66-turn-ended', 'a turn has ended'), - at120x40('cline-3-0-65-win32-startup', 'Windows startup composer') -] -const NOT_READY = [ - at120x40('cline-3-0-66-promo', 'Cline Desktop promo over the composer'), - at120x40('cline-3-0-66-permission', 'tool approval prompt'), - at120x40('cline-3-0-66-slash-menu', 'slash menu open'), - at120x40('cline-3-0-66-draft', 'unsent text in the composer') -] -const STREAMING = 'cline-3-0-66-busy-streaming' - -describe('Cline readiness from captured bytes', () => { - describeScreenRuledAgentTranscripts({ - agent: 'cline', - foregroundProcess: 'cline', - ready: READY, - notReady: NOT_READY, - // Why all: an idle Cline is quiet, so the quiet-process lane settles it. - readyWithoutScreen: READY.map(({ name }) => name) +describe('Muse captured workspace readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) }) - // Presence precondition for the restored-pane suite: the read projection blanks this placeholder. - it('reads the ended turn through a projection that blanks its placeholder', async () => { - const { lines, draft } = await finalReadProjection('cline-3-0-66-turn-ended', 120, 40) - expect(lines).toContain('❯') - expect(draft).toBe('Ask anything...') + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined }) - // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. - it('paints the same empty composer while a reply streams', async () => { - const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) - expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true) + describe('Muse readiness from captured terminal bytes', () => { + it('recognizes a ready folder workspace without a skills summary', async () => { + const data = readFileSync( + join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), + 'utf8' + ) + expect(data).toContain(String.fromCharCode(27)) + expect(data).not.toContain('Skills:') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'muse-first-class-workspace', + foregroundProcess: 'muse-bin-1.3.0-R3401.1', + launchAgent: 'muse', + data + }) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 10_000)).resolves.toMatchObject({ + satisfied: true + }) + }, 15_000) }) +}) - it('refuses every frame whose spinner row is still on screen', async () => { - let spinnerFrames = 0 - for await (const { ruledScreenLines } of replayTranscript( - readRuntimeFixture(STREAMING), - 120, - 40 - )) { - if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { - spinnerFrames += 1 - expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false) +describe('Qoder captured startup readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) + }) + + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined + }) + + describe('captured Qoder 1.1.64 startup', () => { + it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( + 'preserves Qoder identity in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(title).toContain(' | Ready') + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qodercli-1.1.64', + launchAgent: 'qoder', + data, + size: { cols: 100, rows: 32 } + }) + const shown = await runtime.showTerminal(handle) + expect(shown.agentIdentity).toBe('qoder') + if (fixture === 'qoder-trust-dialog') { + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 600).catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + if (fixture === 'qoder-ready') { + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 1500) + expect(readiness.satisfied).toBe(true) + } } - } - // Presence precondition: the thinking spinner was painted above the composer. - expect(spinnerFrames).toBeGreaterThan(0) + ) }) - it('does not settle a streaming pane before it goes quiet', async () => { - const { runtime, handle } = await createTranscriptPane({ + describe('captured Qoder China 1.1.65 startup', () => { + it.each(['qoder-cn-startup', 'qoder-cn-signin'])( + 'checks the composer in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qoderclicn', + launchAgent: 'qoder-cn', + data, + size: { cols: 120, rows: 40 } + }) + expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn') + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 800).catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + ) + }) +}) + +describe('ZCode captured composer readiness', () => { + function readTranscript(): string { + return readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') + } + + describe('ZCode readiness from captured terminal bytes', () => { + it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'worker-zcode', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data: '\x1b[?1049h╭' + }) + await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + }) + + it('never emits an OSC title, so no title lane can settle its wait', () => { + const data = readTranscript() + expect(data).toContain(String.fromCharCode(27)) + // Why: this absence is the whole reason ZCode needs a body-evidence readiness lane. + expect(data).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\][0-2];`)) + }) + + it('keeps repainting long after the composer mounts, so a quiet window never settles', () => { + const data = readTranscript() + const composerIndex = data.indexOf('╭') + expect(composerIndex).toBeGreaterThan(-1) + // Why: ~175KB of banner animation after the composer is up. A quiet-render window + // measured in hundreds of ms cannot fire anywhere in that span. + expect(data.length - composerIndex).toBeGreaterThan(100_000) + }) + + it('leaves no durable readiness evidence in the wait-text tail', async () => { + const data = readTranscript() + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'zcode-first-class-workspace', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data + }) + // Why this asserts a NEGATIVE: Orca's wait text is a line-folded tail, and ZCode paints + // its composer once and then repaints only the banner — so the composer scrolls out and + // no screen rule can settle the wait. This is the evidence for driving ZCode readiness + // from its synthetic hook title instead (see synthetic-agent-title.ts). + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + + it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => { + expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready') + expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required') + expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true) + expect( + hasExplicitIdleTitle({ lastAgentStatus: 'idle', lastOutputAt: Date.now() }, 'ZCode ready') + ).toBe(true) + }) + + it('fires the draft-paste signal at the composer mount, not at the hard timeout', () => { + const data = readTranscript() + const scanner = createDraftPasteReadyScanner('zcode-composer-prompt') + const composerIndex = data.indexOf('╭') + // Why: feeding the stream in PTY-sized chunks proves the marker survives chunk splits. + let readyAt: number | null = null + for (let offset = 0; offset < data.length; offset += 4096) { + const chunk = data.slice(offset, offset + 4096) + if (scanner.observe(chunk).ready) { + readyAt = offset + chunk.length + break + } + } + expect(readyAt).not.toBeNull() + // Ready lands on the chunk that carries the composer corner, not thousands of frames later. + expect(readyAt!).toBeGreaterThanOrEqual(composerIndex) + expect(readyAt!).toBeLessThan(composerIndex + 8192) + }) + }) +}) + +describe('Shell titles during agent startup', () => { + /** + * A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's + * bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name, + * the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still + * booting, or into the shell. A launch readiness wait holds that title to a quiet stream. + */ + + const ESC = String.fromCharCode(27) + const BEL = String.fromCharCode(7) + const BOOT_FRAME = 'Loading…\r\n' + + /** The shell echoes the launch line, then its preexec hook titles the pane with the command. */ + async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') { + return createTranscriptPane({ paneTitle: 'Terminal', - foregroundProcess: 'cline', - launchAgent: 'cline', - data: readRuntimeFixture(STREAMING), - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - // Why 2.5s: inside the 3s quiescence window, past the 2s poll. - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) - }, 15_000) + foregroundProcess: agent, + launchAgent: agent, + data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}` + }) + } + + /** The agent is still painting its boot screen: output keeps arriving each second. */ + async function keepBooting( + runtime: Awaited>['runtime'], + seconds: number + ): Promise { + for (let second = 0; second < seconds; second += 1) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now()) + await vi.advanceTimersByTimeAsync(1000) + } + } + + describe('a launch waiting on an agent whose shell titled the pane with its name', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal. + it.each(['gemini', 'copilot'] as const)( + 'does not treat the shell’s auto-title as readiness while %s is still booting', + async (agent) => { + const { runtime, handle } = await launchedPaneTitledByShell(agent) + const settled = vi.fn() + runtime + .waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 60_000, + launchReadiness: true + }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).not.toHaveBeenCalled() + } + ) + + it('settles once the agent’s stream goes quiet under that title', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 60_000, + launchReadiness: true + }) + .then(settled, settled) + + await keepBooting(runtime, 4) + await vi.advanceTimersByTimeAsync(8_000) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) + + it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) + }) }) diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index 7ebc7a5d2b86..491e974e2294 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -15,6 +15,8 @@ import { isKnownReadyPromptSettled, isQuietReadyScreenBody } from './terminal-wait-detection' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' +import { clearWorkingIndicators } from '../../shared/agent-title-status' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -23,300 +25,587 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628. -const PLAIN = 'codex-0157-plain-ready' -const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' -const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' -const NO_DAEMON = 'codex-0157-no-daemon-effort-override' -// Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. -const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' -const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] - -async function finalFrame( - name: string, - cols: number, - rows: number -): Promise { - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - last = frame +describe('Codex captured header readiness', () => { + // codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628. + const PLAIN = 'codex-0157-plain-ready' + const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' + const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' + const NO_DAEMON = 'codex-0157-no-daemon-effort-override' + // Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. + const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' + const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] + + async function finalFrame( + name: string, + cols: number, + rows: number + ): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + last = frame + } + if (!last) { + throw new Error(`empty fixture ${name}`) + } + return last } - if (!last) { - throw new Error(`empty fixture ${name}`) + + function screenShowsLoadingHeader(screenLines: string[]): boolean { + const screen = screenLines.join('\n').toLowerCase() + return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) } - return last -} - -function screenShowsLoadingHeader(screenLines: string[]): boolean { - const screen = screenLines.join('\n').toLowerCase() - return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) -} - -// Codex's default status row opens with ` ·`; only the live chat paints it. -const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ - -function screenShowsProvisionalStartup(screenLines: string[]): boolean { - return ( - screenShowsLoadingHeader(screenLines) && - !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) - ) -} - -describe('Codex 0.157 header readiness from captured bytes', () => { - it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( - '%s: the line-folded wait text never shows a ready header', - async (name) => { - const { waitText } = await finalFrame(name, 120, 40) - // Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout. - expect(waitText.toLowerCase()).toContain('dirctory:') - expect(isKnownReadyPromptPreview(waitText)).toBe(false) - } - ) - - it.each(ALL_FIXTURES)( - '%s: the screen never adds readiness while loading, and is ready at the final screen', - async (name) => { - let sawLoadingHeader = false - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - if (screenShowsLoadingHeader(frame.screenLines)) { - sawLoadingHeader = true - expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false) - } - last = frame + + // Codex's default status row opens with ` ·`; only the live chat paints it. + const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ + + function screenShowsProvisionalStartup(screenLines: string[]): boolean { + return ( + screenShowsLoadingHeader(screenLines) && + !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) + ) + } + + describe('Codex 0.157 header readiness from captured bytes', () => { + it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( + '%s: the line-folded wait text never shows a ready header', + async (name) => { + const { waitText } = await finalFrame(name, 120, 40) + // Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout. + expect(waitText.toLowerCase()).toContain('dirctory:') + expect(isKnownReadyPromptPreview(waitText)).toBe(false) } - // Presence precondition: a loading frame was actually exercised. - expect(sawLoadingHeader).toBe(true) - expect(last).not.toBeNull() - expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) - } - ) - - // Why: 0.157 discards input typed during its daemon start, behind the provisional header. - it.each(ALL_FIXTURES)( - '%s: never ready while the screen shows the provisional `model: loading` startup screen', - async (name) => { - let sawTextOnlyReadiness = false - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - if (screenShowsProvisionalStartup(frame.screenLines)) { - sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - false - ) - expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( - false - ) + ) + + it.each(ALL_FIXTURES)( + '%s: the screen never adds readiness while loading, and is ready at the final screen', + async (name) => { + let sawLoadingHeader = false + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsLoadingHeader(frame.screenLines)) { + sawLoadingHeader = true + expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false) + } + last = frame } + // Presence precondition: a loading frame was actually exercised. + expect(sawLoadingHeader).toBe(true) + expect(last).not.toBeNull() + expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) } - // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. - if (name === PLAIN || name === FRESH_HOME) { - expect(sawTextOnlyReadiness).toBe(true) - } - } - ) - - // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). - describe.each([ - [120, 40], - [80, 24], - [30, 50], - [108, 30], - [60, 5] - ])('at %ix%i the screen never takes a settled header away from the text rules', (cols, rows) => { - it.each(ALL_FIXTURES)('%s', async (name) => { - let settledFrames = 0 - for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - if (isKnownReadyPromptSettled(frame.waitText)) { - settledFrames += 1 - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - true - ) + ) + + // Why: 0.157 discards input typed during its daemon start, behind the provisional header. + it.each(ALL_FIXTURES)( + '%s: never ready while the screen shows the provisional `model: loading` startup screen', + async (name) => { + let sawTextOnlyReadiness = false + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsProvisionalStartup(frame.screenLines)) { + sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect( + isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true) + ).toBe(false) + } + } + // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. + if (name === PLAIN || name === FRESH_HOME) { + expect(sawTextOnlyReadiness).toBe(true) } } - // Presence precondition: the text-copy fixtures reach a settled header. - if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { - expect(settledFrames).toBeGreaterThan(0) + ) + + // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). + describe.each([ + [120, 40], + [80, 24], + [30, 50], + [108, 30], + [60, 5] + ])( + 'at %ix%i the screen never takes a settled header away from the text rules', + (cols, rows) => { + it.each(ALL_FIXTURES)('%s', async (name) => { + let settledFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + if (isKnownReadyPromptSettled(frame.waitText)) { + settledFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + true + ) + } + } + // Presence precondition: the text-copy fixtures reach a settled header. + if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { + expect(settledFrames).toBeGreaterThan(0) + } + }) } + ) + + it('keeps the text rules when there is no live screen', async () => { + const { waitText } = await finalFrame(PLAIN, 120, 40) + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe( + isKnownReadyPromptPreview(waitText) + ) + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true) }) - }) - it('keeps the text rules when there is no live screen', async () => { - const { waitText } = await finalFrame(PLAIN, 120, 40) - expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe( - isKnownReadyPromptPreview(waitText) - ) - expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true) - }) + it('does not read a mid-turn composer as ready', () => { + const screenLines = [ + '› Summarize the repository layout', + '• Working (12s • esc to interrupt)', + '› Ask Codex to do anything', + ' GPT-6-Sol high · ~/repo/app' + ] + expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) + }) - it('does not read a mid-turn composer as ready', () => { - const screenLines = [ - '› Summarize the repository layout', - '• Working (12s • esc to interrupt)', - '› Ask Codex to do anything', - ' GPT-6-Sol high · ~/repo/app' - ] - expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) - }) + it('does not settle when a blocking dialog is painted below the header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + 'Do you trust the contents of this directory?', + 'Press enter to continue' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false) + }) - it('does not settle when a blocking dialog is painted below the header', () => { - const screenLines = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │', - 'Do you trust the contents of this directory?', - 'Press enter to continue' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false) - }) + it('reads only the header box, not chat below it that mentions Codex', () => { + const screenLines = [ + '╭──────────────────────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────────────────────╯', + '› Why does OpenAI Codex print model: loading at startup?' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) - it('reads only the header box, not chat below it that mentions Codex', () => { - const screenLines = [ - '╭──────────────────────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────────────────────╯', - '› Why does OpenAI Codex print model: loading at startup?' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - }) + it('never reads a non-codex screen, even one showing the Codex header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] + const readScreenLines = vi.fn(() => screenLines) + expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) - it('never reads a non-codex screen, even one showing the Codex header', () => { - const screenLines = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │' - ] - const readScreenLines = vi.fn(() => screenLines) - expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false) - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) + describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { + it.each(ALL_FIXTURES)('%s', async (name) => { + const { screenLines, waitText } = await finalFrame(name, 80, 24) + expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe( + isKnownReadyPromptPreview(waitText) + ) + }) + }) - describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { - it.each(ALL_FIXTURES)('%s', async (name) => { - const { screenLines, waitText } = await finalFrame(name, 80, 24) - expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe( - isKnownReadyPromptPreview(waitText) + describe('through the runtime', () => { + async function codexPane(name: string, size?: { cols: number; rows: number }) { + const created = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size + }) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created + } + + it.each(ALL_FIXTURES)( + '%s: a tui-idle wait settles from the live screen', + async (name) => { + const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, + 15_000 ) + + it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { + const data = readRuntimeFixture(FRESH_HOME) + const install = data.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = data.slice(0, data.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: provisional, + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) + + // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the + // runtime asks for a visible-screen read instead of its own text copy. + it('does not settle from a visible-screen read of the provisional screen', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '', + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.0) │', + '│ model: loading /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' ? for shortcuts' + ], + truncated: false, + nextCursor: null, + source: 'screen' + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) + // Presence precondition: the visible-screen probe actually ran. + expect(readVisibleScreen).toHaveBeenCalled() + }, 15_000) + + // Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out. + it('settles a restored Codex pane from its header, as main did', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '' + }) + await runtime.readTerminal(handle, { screen: true }) + runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { + text: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: gpt-6-sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' gpt-6-sol high · ~/repo/app' + ].join('\r\n') + }) + // Why no screen: the visible-screen probe must not be what settles the wait. + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [], + truncated: false, + nextCursor: null, + source: 'screen-unavailable' + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it('keeps timing out on the garbled 80x24 default grid, as before', async () => { + const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) }) }) +}) - describe('through the runtime', () => { - async function codexPane(name: string, size?: { cols: number; rows: number }) { - const created = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: readRuntimeFixture(name), - size - }) - await created.runtime.readTerminal(created.handle, { screen: true }) - return created +describe('Codex launched composer readiness', () => { + // A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex + // release that the launch wait cannot read ready reports "prompt wasn't sent". + async function launchedCodexPane(data: string) { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data, + size: { cols: 120, rows: 40 } + }) + } + + async function waitForCodexComposer( + pane: Awaited>, + timeoutMs: number, + streamedData?: string + ) { + // Pane creation uses real timers; only the readiness budget advances virtually. + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + try { + const ready = waitForLaunchedAgentComposer(pane.runtime, pane.handle, 'codex', timeoutMs) + void ready.catch(() => {}) + if (streamedData !== undefined) { + pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, streamedData, Date.now()) + } + await vi.advanceTimersByTimeAsync(timeoutMs + 1) + return await ready + } finally { + vi.useRealTimers() } + } - it.each(ALL_FIXTURES)( - '%s: a tui-idle wait settles from the live screen', - async (name) => { - const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) - // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true + // Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every + // 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed + // whole it holds a starred composer still, which the live stream never does. + function greetingBeforeStarfield(): string { + const data = readRuntimeFixture('codex-0158-fresh-home-greeting') + const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/) + return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar)) + } + + describe('launch readiness for a freshly launched Codex', () => { + it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { + const data = greetingBeforeStarfield() + // Presence precondition: the cut keeps the live status row and drops every star. + expect(data).toContain('GPT-6-Astra default') + expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/) + const pane = await launchedCodexPane(data) + await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { + const pane = await launchedCodexPane(readRuntimeFixture('codex-0157-plain-ready')) + await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it.each([ + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s: stops at the dialog as %s instead of pasting into it', + async (fixture, reason) => { + const pane = await launchedCodexPane(readRuntimeFixture(fixture)) + await expect(waitForCodexComposer(pane, 2_500)).resolves.toMatchObject({ + satisfied: false, + blockedReason: reason + }) + }, + 15_000 + ) + + // Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the + // composer signal, so only the screen check keeps it from pasting; it hands the dialog to + // `tui-idle` to report, well inside the desktop paste's budget. + it.each([ + ['codex-0157-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s streamed in after the launch: reports the dialog as %s, never as the composer', + async (fixture, reason) => { + const data = readRuntimeFixture(fixture) + // Presence precondition: the dialog draws Codex's composer glyph after bracketed paste. + expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›') + const pane = await launchedCodexPane('') + await expect(waitForCodexComposer(pane, 2_500, data)).resolves.toMatchObject({ + satisfied: false, + blockedReason: reason }) }, 15_000 ) - it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { - const data = readRuntimeFixture(FRESH_HOME) - const install = data.indexOf('Installing daemon') + // Why: 0.157 discards input typed behind its provisional `model: loading` screen. + it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => { + const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install') + const install = full.indexOf('Installing daemon') // Presence precondition: the cut keeps the provisional header and stops before the live chat. expect(install).toBeGreaterThan(0) - const provisional = data.slice(0, data.indexOf('\n', install) + 1) + const provisional = full.slice(0, full.indexOf('\n', install) + 1) expect(provisional).toMatch(/model:.*loading/) - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: provisional, - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + const pane = await launchedCodexPane(provisional) + await expect(waitForCodexComposer(pane, 5_000)).rejects.toThrow(/timeout/) }, 15_000) + }) +}) - // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the - // runtime asks for a visible-screen read instead of its own text copy. - it('does not settle from a visible-screen read of the provisional screen', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: '', - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle, - status: 'running', - tail: [ - '╭──────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.0) │', - '│ model: loading /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────╯', - '› Ask Codex to do anything', - ' ? for shortcuts' - ], - truncated: false, - nextCursor: null, - source: 'screen' - }) - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) - // Presence precondition: the visible-screen probe actually ran. - expect(readVisibleScreen).toHaveBeenCalled() - }, 15_000) +describe('Display title clearing and live agent presence', () => { + // Synthetic protocol cases: they test which title readiness, display and presence each read, + // not what a particular CLI paints. The 3 s timer is the stale-working-title clear. + describe('the stale-working title clear is display-only', () => { + it.each([ + ['claude', '. claude', '* claude'], + ['codex', '⠋ Codex working', 'Codex ready'], + ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], + ['opencode', '⠋ OC | task', 'OC | task'], + ['pi', 'π : project', 'π - project'] + ] as const)( + '%s does not become ready when its display title clears', + async (agent, busy, idle) => { + vi.useFakeTimers() + try { + const titleChanges: string[] = [] + const { runtime, handle } = await createTranscriptPane( + { + paneTitle: busy, + foregroundProcess: agent, + data: '', + launchAgent: agent + }, + { + onTerminalSideEffects: (batch) => { + for (const fact of batch.facts) { + if (fact.kind === 'title') { + titleChanges.push(fact.normalizedTitle) + } + } + } + } + ) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) + // A renderer graph publish can carry the display-only cleared title back to main. + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: clearWorkingIndicators(busy), + activeLeafId: '11111111-1111-4111-8111-111111111111', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: clearWorkingIndicators(busy) + } + ] + }) + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + const assertion = expect(waiting).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) + const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) + await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) + } finally { + vi.useRealTimers() + } + } + ) + + // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. + it.each([['⠋ repo']])( + 'an agent that exited behind %s does not pass for a running agent', + async (title) => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'zsh', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) + // The agent exits and the shell prints its prompt: output, but no title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + } + ) + + it('a live agent behind a cleared title is still found by its process', async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("the agent's next genuine title proves presence again", async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + } finally { + vi.useRealTimers() + } + }) - // Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out. - it('settles a restored Codex pane from its header, as main did', async () => { + it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { const { runtime, handle } = await createTranscriptPane({ paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', + foregroundProcess: null, data: '' }) - await runtime.readTerminal(handle, { screen: true }) - runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { - text: [ - '╭──────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: gpt-6-sol high /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────╯', - '› Ask Codex to do anything', - ' gpt-6-sol high · ~/repo/app' - ].join('\r\n') + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' }) - // Why no screen: the visible-screen probe must not be what settles the wait. - vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle, - status: 'running', - tail: [], - truncated: false, - nextCursor: null, - source: 'screen-unavailable' - }) - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it('keeps timing out on the garbled 80x24 default grid, as before', async () => { - const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) - }, 15_000) + }) }) }) diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index db6077a723e6..032f89908a54 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -21,6 +21,7 @@ import { isTuiIdleReadyVerdict, type TuiIdleEvaluationInput } from './tui-idle-evidence' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -29,272 +30,336 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const QUIESCENCE_MS = 3000 - -// codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, -// and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. -const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] -const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] -const DIALOG_FIXTURES = [ - 'codex-0-157-1-update-dialog', - 'codex-0-158-0-approval', - 'codex-0-158-0-trustprompt' -] -const STA_8628_FIXTURES = [ - 'codex-0157-plain-ready', - 'codex-0157-effort-override-embedded-warning', - 'codex-0157-config-override-embedded-warning', - 'codex-0157-no-daemon-effort-override' -] - -const BUSY_STATUS_RE = /to interrupt\)/ -const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m -const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m -const DIALOG_RE = - /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ - -function screenOf(frame: TranscriptReplayFrame): string { - return frame.screenLines.join('\n').toLowerCase() -} - -async function collectFrames( - data: string | readonly string[], - cols = 120, - rows = 40 -): Promise { - const frames: TranscriptReplayFrame[] = [] - for await (const frame of replayTranscript(data, cols, rows)) { - frames.push(frame) +describe('Codex quiet ready screen', () => { + const QUIESCENCE_MS = 3000 + + // codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, + // and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. + const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] + const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] + const DIALOG_FIXTURES = [ + 'codex-0-157-1-update-dialog', + 'codex-0-158-0-approval', + 'codex-0-158-0-trustprompt' + ] + const STA_8628_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override' + ] + + const BUSY_STATUS_RE = /to interrupt\)/ + const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m + const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m + const DIALOG_RE = + /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ + + function screenOf(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n').toLowerCase() } - return frames -} - -describe('Codex composer ready screen, frame by frame', () => { - it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( - '%s: never ready while loading, mid-turn, or under a drawn dialog', - async (name) => { - const seen = { loading: 0, busy: 0, dialog: 0 } - for (const frame of await collectFrames(readRuntimeFixture(name))) { - const screen = screenOf(frame) - // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, - // which quiescence absorbs; a drawn dialog replaces it. - const kind = HEADER_LOADING_RE.test(screen) - ? 'loading' - : BUSY_STATUS_RE.test(screen) - ? 'busy' - : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) - ? 'dialog' - : null - if (kind) { - seen[kind] += 1 - expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ - kind, - ready: false - }) + + async function collectFrames( + data: string | readonly string[], + cols = 120, + rows = 40 + ): Promise { + const frames: TranscriptReplayFrame[] = [] + for await (const frame of replayTranscript(data, cols, rows)) { + frames.push(frame) + } + return frames + } + + describe('Codex composer ready screen, frame by frame', () => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( + '%s: never ready while loading, mid-turn, or under a drawn dialog', + async (name) => { + const seen = { loading: 0, busy: 0, dialog: 0 } + for (const frame of await collectFrames(readRuntimeFixture(name))) { + const screen = screenOf(frame) + // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, + // which quiescence absorbs; a drawn dialog replaces it. + const kind = HEADER_LOADING_RE.test(screen) + ? 'loading' + : BUSY_STATUS_RE.test(screen) + ? 'busy' + : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) + ? 'dialog' + : null + if (kind) { + seen[kind] += 1 + expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ + kind, + ready: false + }) + } } + // Presence preconditions: each fixture exercises the states it was recorded for. + expect(seen.loading).toBeGreaterThan(0) + if (DIALOG_FIXTURES.includes(name)) { + expect(seen.dialog).toBeGreaterThan(0) + } else { + expect(seen.busy).toBeGreaterThan(0) + } + }, + 30_000 + ) + + it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( + '%s: ready on the final idle screen', + async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) } - // Presence preconditions: each fixture exercises the states it was recorded for. - expect(seen.loading).toBeGreaterThan(0) - if (DIALOG_FIXTURES.includes(name)) { - expect(seen.dialog).toBeGreaterThan(0) - } else { - expect(seen.busy).toBeGreaterThan(0) - } - }, - 30_000 - ) + ) - it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( - '%s: ready on the final idle screen', - async (name) => { + it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { const frames = await collectFrames(readRuntimeFixture(name)) - expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) - } - ) - - it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { - const frames = await collectFrames(readRuntimeFixture(name)) - expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) - }) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) + }) - it('is not ready without a live screen', () => { - expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) - }) + it('is not ready without a live screen', () => { + expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) + }) - it('reads the placeholder only as the composer line, not quoted mid-line', () => { - const quoted = ['$ grep placeholder notes.txt', 'the box says › Ask Codex to do anything', '$'] - expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) - }) + it('reads the placeholder only as the composer line, not quoted mid-line', () => { + const quoted = [ + '$ grep placeholder notes.txt', + 'the box says › Ask Codex to do anything', + '$' + ] + expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) + }) - it('reads the busy row only above the composer, not a status row quoted in the answer', () => { - const screenLines = [ - '>_ OpenAI Codex (v0.158.0)', - ' ~/repo', - '', - '› what does the status row look like mid-turn?', - '', - '• It reads like this:', - '', - ' • Working (0s • esc to interrupt)', - '', - ' The timer counts up until the turn ends, and the row', - ' disappears once the answer is complete.', - '', - '› Ask Codex to do anything', - '', - ' gpt-5.6-sol medium · ~/repo', - ' ? for shortcuts' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - const composer = screenLines.indexOf('› Ask Codex to do anything') - const busy = [ - ...screenLines.slice(0, composer), - '• Working (3s • esc to interrupt)', - ' └ Tip: start a fresh idea with /new', - '', - ...screenLines.slice(composer) - ] - expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) - }) + it('reads the busy row only above the composer, not a status row quoted in the answer', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› what does the status row look like mid-turn?', + '', + '• It reads like this:', + '', + ' • Working (0s • esc to interrupt)', + '', + ' The timer counts up until the turn ends, and the row', + ' disappears once the answer is complete.', + '', + '› Ask Codex to do anything', + '', + ' gpt-5.6-sol medium · ~/repo', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + const composer = screenLines.indexOf('› Ask Codex to do anything') + const busy = [ + ...screenLines.slice(0, composer), + '• Working (3s • esc to interrupt)', + ' └ Tip: start a fresh idea with /new', + '', + ...screenLines.slice(composer) + ] + expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) + }) - it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { - // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). - const screenLines = [ - ' === TASK ===', - ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', - '• • Working (0s • esc to interrupt)', - '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', - ' └ Sent msg_ff7be3f25d7e', - ' + Show details', - '• • Working (0s • esc to interrupt)', - ' 11:15 PM', - '› Ask Codex to do anything', - ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', - ' ? for shortcuts' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - }) + it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { + // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). + const screenLines = [ + ' === TASK ===', + ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', + '• • Working (0s • esc to interrupt)', + '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', + ' └ Sent msg_ff7be3f25d7e', + ' + Show details', + '• • Working (0s • esc to interrupt)', + ' 11:15 PM', + '› Ask Codex to do anything', + ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) - it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { - const screenLines = [ - '>_ OpenAI Codex (v0.158.0)', - ' ~/repo', - '', - '› tidy the README', - '', - '• Done. Would you like to proceed with the changelog too?', - '', - '› Ask Codex to do anything', - '', - ' ? for shortcuts' - ] - const waitText = screenLines.join('\n') - const verdict = evaluateTuiIdle({ - record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText, 'codex', () => screenLines), - agent: 'codex', - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS + it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› tidy the README', + '', + '• Done. Would you like to proceed with the changelog too?', + '', + '› Ask Codex to do anything', + '', + ' ? for shortcuts' + ] + const waitText = screenLines.join('\n') + const verdict = evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + expect(verdict.kind).toBe('ready-strong') }) - expect(verdict.kind).toBe('ready-strong') }) -}) -type Timing = { promptSentAtMs: number; chunks: [number, number][] } - -function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { - const data = readRuntimeFixture(name) - const timing: Timing = JSON.parse( - readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') - ) - const chunks: string[] = [] - let offset = 0 - for (const [, length] of timing.chunks) { - chunks.push(data.slice(offset, offset + length)) - offset += length + type Timing = { promptSentAtMs: number; chunks: [number, number][] } + + function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { + const data = readRuntimeFixture(name) + const timing: Timing = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') + ) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + expect(offset).toBe(data.length) + return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } } - expect(offset).toBe(data.length) - return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } -} -describe('the quiet lane over recorded chunk timing (default animations)', () => { - afterEach(() => { - vi.useRealTimers() + describe('the quiet lane over recorded chunk timing (default animations)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(TIMED_FIXTURES)( + '%s: never settles mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + // Why after the replay: the emulator's write flush runs on real timers. + vi.useFakeTimers() + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + expect(lastBusy).toBeGreaterThan(firstTurnChunk) + // The latest moment each frame stays on screen: just before the next chunk lands. + const settlesAt = (index: number, now: number): boolean => { + vi.setSystemTime(now) + return hasQuietReadyScreen( + { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + 'codex', + () => + isQuietReadyScreenBody( + frames[index]!.waitText, + 'codex', + () => frames[index]!.screenLines + ), + QUIESCENCE_MS + ) + } + let readyBodyMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { + readyBodyMidTurn += 1 + } + expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ + index, + settles: false + }) + } + // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. + expect(readyBodyMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. + expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) + }, + 120_000 + ) }) - it.each(TIMED_FIXTURES)( - '%s: never settles mid-turn, and settles once the finished turn is quiet', - async (name) => { - const { chunks, times, promptAt } = readTimedFixture(name) - const frames = await collectFrames(chunks) - // Why after the replay: the emulator's write flush runs on real timers. - vi.useFakeTimers() - const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) - const firstTurnChunk = times.findIndex((at) => at >= promptAt) - expect(lastBusy).toBeGreaterThan(firstTurnChunk) - // The latest moment each frame stays on screen: just before the next chunk lands. - const settlesAt = (index: number, now: number): boolean => { - vi.setSystemTime(now) - return hasQuietReadyScreen( - { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, - 'codex', - () => - isQuietReadyScreenBody( - frames[index]!.waitText, - 'codex', - () => frames[index]!.screenLines - ), - QUIESCENCE_MS - ) - } - let readyBodyMidTurn = 0 - for (let index = firstTurnChunk; index <= lastBusy; index += 1) { - if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { - readyBodyMidTurn += 1 + describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(['codex-0-155-1-timed-turn', 'codex-0-157-1-timed-sleep-turn'])( + '%s: never settles tui-idle mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + vi.useFakeTimers() + const verdictAt = (index: number, now: number) => { + vi.setSystemTime(now) + const { waitText, screenLines } = frames[index]! + return evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) } - expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ - index, - settles: false - }) - } - // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. - expect(readyBodyMidTurn).toBeGreaterThan(0) - const last = frames.length - 1 - // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. - expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) - }, - 120_000 - ) -}) + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + let headerMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isKnownReadyPromptPreview(frames[index]!.waitText)) { + headerMidTurn += 1 + } + const settles = isTuiIdleReadyVerdict(verdictAt(index, times[index + 1]! - 1)) + expect({ index, settles }).toEqual({ index, settles: false }) + } + // Presence precondition: the ready header is in the tail mid-turn, so the fix is load-bearing. + expect(headerMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + expect(verdictAt(last, times[last]!).kind).toBe('pending') + expect(verdictAt(last, times[last]! + QUIESCENCE_MS + 1).kind).toBe('ready-strong') + }, + 120_000 + ) -describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { - afterEach(() => { - vi.useRealTimers() - }) + const header = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] - it.each(['codex-0-155-1-timed-turn', 'codex-0-157-1-timed-sleep-turn'])( - '%s: never settles tui-idle mid-turn, and settles once the finished turn is quiet', - async (name) => { - const { chunks, times, promptAt } = readTimedFixture(name) - const frames = await collectFrames(chunks) - vi.useFakeTimers() - const verdictAt = (index: number, now: number) => { - vi.setSystemTime(now) - const { waitText, screenLines } = frames[index]! - return evaluateTuiIdle({ - record: { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + it('reads the header as tier-1 evidence only for a pane with no output clock', () => { + const waitText = header.join('\n') + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, true)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, false)).toBe(true) + expect(isKnownReadyPromptBody(waitText, null, () => header, true)).toBe(true) + }) + + // Why: a restored or reattached pane has no lastOutputAt, so the quiet lane can never fire. + const NOW = 60_000 + it.each([ + [null, 'ready-strong'], + [0, 'ready-strong'], + [NOW - 1_000, 'pending'] + ] as const)( + 'a codex pane whose lastOutputAt is %s reads its header as %s', + (lastOutputAt, kind) => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const waitText = header.join('\n') + const record = { lastAgentStatus: null, lastOutputAt, lastOscTitle: null } + const verdict = evaluateTuiIdle({ + record, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), agent: 'codex', readAgentRuleVerdict: () => null, readScreenInputVeto: () => null, @@ -302,172 +367,305 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) + expect(verdict.kind).toBe(kind) } - const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) - const firstTurnChunk = times.findIndex((at) => at >= promptAt) - let headerMidTurn = 0 - for (let index = firstTurnChunk; index <= lastBusy; index += 1) { - if (isKnownReadyPromptPreview(frames[index]!.waitText)) { - headerMidTurn += 1 - } - const settles = isTuiIdleReadyVerdict(verdictAt(index, times[index + 1]! - 1)) - expect({ index, settles }).toEqual({ index, settles: false }) - } - // Presence precondition: the ready header is in the tail mid-turn, so the fix is load-bearing. - expect(headerMidTurn).toBeGreaterThan(0) - const last = frames.length - 1 - expect(verdictAt(last, times[last]!).kind).toBe('pending') - expect(verdictAt(last, times[last]! + QUIESCENCE_MS + 1).kind).toBe('ready-strong') - }, - 120_000 - ) + ) + }) - const header = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │' - ] + describe('reading the live screen never removes quiet-lane readiness', () => { + const records = [ + { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, + { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } + ] + describe.each([ + [120, 40], + [80, 24] + ])('at %ix%i', (cols, rows) => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( + '%s', + async (name) => { + for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { + for (const agent of ['codex', null] as const) { + const base = { + readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), + agent, + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + } satisfies Omit + for (const record of records) { + const before = evaluateTuiIdle({ + ...base, + record, + // Why a withheld screen: isolates what the live screen adds to the quiet lane. + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => null) + }) + const after = evaluateTuiIdle({ + ...base, + record, + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) + }) + if (isTuiIdleReadyVerdict(before)) { + expect(isTuiIdleReadyVerdict(after)).toBe(true) + } + } + } + } + }, + 60_000 + ) + }) + }) + + describe('agent gate', () => { + const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] + + it("never reads another agent's screen, even one showing Codex's placeholder", () => { + const readScreenLines = vi.fn(() => placeholderScreen) + for (const agent of ['claude', 'muse', 'gemini'] as const) { + expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) + } + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) - it('reads the header as tier-1 evidence only for a pane with no output clock', () => { - const waitText = header.join('\n') - expect(isKnownReadyPromptBody(waitText, 'codex', () => header, true)).toBe(false) - expect(isKnownReadyPromptBody(waitText, 'codex', () => header, false)).toBe(true) - expect(isKnownReadyPromptBody(waitText, null, () => header, true)).toBe(true) + it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { + const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] + const readScreenLines = vi.fn(() => catted) + expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) }) - // Why: a restored or reattached pane has no lastOutputAt, so the quiet lane can never fire. - const NOW = 60_000 - it.each([ - [null, 'ready-strong'], - [0, 'ready-strong'], - [NOW - 1_000, 'pending'] - ] as const)( - 'a codex pane whose lastOutputAt is %s reads its header as %s', - (lastOutputAt, kind) => { - vi.useFakeTimers() - vi.setSystemTime(NOW) - const waitText = header.join('\n') - const record = { lastAgentStatus: null, lastOutputAt, lastOscTitle: null } - const verdict = evaluateTuiIdle({ - record, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), - readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), - agent: 'codex', - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS + describe('through the runtime', () => { + // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. + const CODEX_0158 = 'codex-0-158-0-timed-turn' + async function pane( + name: string, + launchAgent: TuiAgent, + size?: { cols: number; rows: number } + ) { + const created = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: launchAgent, + launchAgent, + data: readRuntimeFixture(name), + size }) - expect(verdict.kind).toBe(kind) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created } - ) + + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it('keeps a Claude pane showing the same screen pending', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'claude', { + cols: 120, + rows: 40 + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) + }) }) -describe('reading the live screen never removes quiet-lane readiness', () => { - const records = [ - { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, - { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, - { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } +describe('Codex startup dialogs', () => { + type StartupDialog = { + name: string + reason: RuntimeTerminalWaitBlockedReason + heading: string + keyRow: string + } + + // codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). + // Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. + const DIALOGS: StartupDialog[] = [ + { + // Recorded separately on 0.157.1 (STA-8834). + name: 'codex-0-157-1-update-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0158-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0158-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0157-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer available', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer offered', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-announcement-dialog', + reason: 'codex-model-migration-prompt', + heading: 'Try new model', + keyRow: 'enter/esc confirm · ctrl+c quit' + } ] - describe.each([ - [120, 40], - [80, 24] - ])('at %ix%i', (cols, rows) => { - it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( - '%s', - async (name) => { - for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { - for (const agent of ['codex', null] as const) { - const base = { - readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), - agent, - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS - } satisfies Omit - for (const record of records) { - const before = evaluateTuiIdle({ - ...base, - record, - // Why a withheld screen: isolates what the live screen adds to the quiet lane. - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(frame.waitText, agent, () => null) - }) - const after = evaluateTuiIdle({ - ...base, - record, - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) - }) - if (isTuiIdleReadyVerdict(before)) { - expect(isTuiIdleReadyVerdict(after)).toBe(true) - } - } + const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) + const LIVE_CHAT_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override', + 'codex-0157-fresh-home-daemon-install', + 'codex-0158-fresh-home-greeting' + ] + + function screenText(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n') + } + + async function lastFrame(data: string): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(data, 120, 40)) { + last = frame + } + return last + } + + // Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup + // dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. + function answered(name: string): string { + const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') + return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) + } + + describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { + it.each(DIALOGS)( + '$name: reports $reason from its key row on, and never reads ready while it is up', + async ({ name, reason, heading, keyRow }) => { + let headingFrames = 0 + let keyRowFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + const screen = screenText(frame) + if (screen.includes(heading)) { + headingFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect( + isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true) + ).toBe(false) + } + if (keyRowFrames > 0 || screen.includes(keyRow)) { + keyRowFrames += 1 + expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) } } - }, - 60_000 + // Presence precondition: the dialog and its key row were painted, not just parsed. + expect(headingFrames).toBeGreaterThan(0) + expect(keyRowFrames).toBeGreaterThan(0) + } ) - }) -}) - -describe('agent gate', () => { - const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] - it("never reads another agent's screen, even one showing Codex's placeholder", () => { - const readScreenLines = vi.fn(() => placeholderScreen) - for (const agent of ['claude', 'muse', 'gemini'] as const) { - expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) - } - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) + it.each(DIALOGS_0158)( + '$name: stops reporting once Codex repaints its chat after it', + async ({ name, heading }) => { + const last = await lastFrame(answered(name)) + // Presence precondition: the answered dialog is still in the text copy. + expect(last?.waitText).toContain(heading.replace(' ·', '')) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() + } + ) - it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { - const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] - const readScreenLines = vi.fn(() => catted) - expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) -}) + it.each(DIALOGS)( + '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', + async ({ name, reason }) => { + // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. + const dialog = readRuntimeFixture(name) + const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) + expect( + isQuietReadyScreenBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) + ).toBe(false) + } + ) -describe('through the runtime', () => { - // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. - const CODEX_0158 = 'codex-0-158-0-timed-turn' - async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) { - const created = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: launchAgent, - launchAgent, - data: readRuntimeFixture(name), - size + it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { + const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. + const chat = [ + '› is there an update available, and do my hooks need review?', + '✨ Update available! 0.158.0 -> 0.159.0', + 'Run npm install -g @openai/codex to update.', + ' gpt-6-astra default · ~/repo', + ' ← for agents · ? for shortcuts' + ].join('\n') + expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() }) - await created.runtime.readTerminal(created.handle, { screen: true }) - return created - } - // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. - it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { - const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true + it('does not name a mid-session Codex popup a hooks review', () => { + // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. + const popup = [ + ' Use this reset?', + ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', + '› 2. No, go back Choose a different reset', + ' enter confirm · esc back' + ].join('\n') + expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') }) - }, 15_000) - it('keeps a Claude pane showing the same screen pending', async () => { - const { runtime, handle } = await pane(CODEX_0158, 'claude', { - cols: 120, - rows: 40 + describe('through the runtime', () => { + it.each(DIALOGS)( + '$name: stops a tui-idle wait as $reason instead of typing into it', + async ({ name, reason }) => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) }) - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) - }, 15_000) + }) }) diff --git a/src/main/runtime/codex-startup-dialog-transcripts.test.ts b/src/main/runtime/codex-startup-dialog-transcripts.test.ts deleted file mode 100644 index ac636eb8a052..000000000000 --- a/src/main/runtime/codex-startup-dialog-transcripts.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' -import { - readRuntimeFixture, - replayTranscript, - type TranscriptReplayFrame -} from './agent-transcript-replay-test-harness' -import { - detectTerminalWaitBlockedReason, - isKnownReadyPromptBody, - isQuietReadyScreenBody -} from './terminal-wait-detection' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -type StartupDialog = { - name: string - reason: RuntimeTerminalWaitBlockedReason - heading: string - keyRow: string -} - -// codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). -// Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. -const DIALOGS: StartupDialog[] = [ - { - // Recorded separately on 0.157.1 (STA-8834). - name: 'codex-0-157-1-update-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0157-update-available-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0158-update-available-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0157-hooks-review-dialog', - reason: 'agent-hooks-review-prompt', - heading: 'Hooks need review', - keyRow: 'enter confirm · esc skip' - }, - { - name: 'codex-0158-hooks-review-dialog', - reason: 'agent-hooks-review-prompt', - heading: 'Hooks need review', - keyRow: 'enter confirm · esc skip' - }, - { - name: 'codex-0157-model-retired-dialog', - reason: 'codex-model-migration-prompt', - heading: 'is no longer available', - keyRow: 'enter/esc continue · ctrl+c quit' - }, - { - name: 'codex-0158-model-retired-dialog', - reason: 'codex-model-migration-prompt', - heading: 'is no longer offered', - keyRow: 'enter/esc continue · ctrl+c quit' - }, - { - name: 'codex-0158-model-announcement-dialog', - reason: 'codex-model-migration-prompt', - heading: 'Try new model', - keyRow: 'enter/esc confirm · ctrl+c quit' - } -] -const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) -const LIVE_CHAT_FIXTURES = [ - 'codex-0157-plain-ready', - 'codex-0157-effort-override-embedded-warning', - 'codex-0157-config-override-embedded-warning', - 'codex-0157-no-daemon-effort-override', - 'codex-0157-fresh-home-daemon-install', - 'codex-0158-fresh-home-greeting' -] - -function screenText(frame: TranscriptReplayFrame): string { - return frame.screenLines.join('\n') -} - -async function lastFrame(data: string): Promise { - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(data, 120, 40)) { - last = frame - } - return last -} - -// Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup -// dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. -function answered(name: string): string { - const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') - return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) -} - -describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { - it.each(DIALOGS)( - '$name: reports $reason from its key row on, and never reads ready while it is up', - async ({ name, reason, heading, keyRow }) => { - let headingFrames = 0 - let keyRowFrames = 0 - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - const screen = screenText(frame) - if (screen.includes(heading)) { - headingFrames += 1 - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - false - ) - expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( - false - ) - } - if (keyRowFrames > 0 || screen.includes(keyRow)) { - keyRowFrames += 1 - expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) - } - } - // Presence precondition: the dialog and its key row were painted, not just parsed. - expect(headingFrames).toBeGreaterThan(0) - expect(keyRowFrames).toBeGreaterThan(0) - } - ) - - it.each(DIALOGS_0158)( - '$name: stops reporting once Codex repaints its chat after it', - async ({ name, heading }) => { - const last = await lastFrame(answered(name)) - // Presence precondition: the answered dialog is still in the text copy. - expect(last?.waitText).toContain(heading.replace(' ·', '')) - expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() - } - ) - - it.each(DIALOGS)( - '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', - async ({ name, reason }) => { - // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. - const dialog = readRuntimeFixture(name) - const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) - expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) - expect( - isQuietReadyScreenBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) - ).toBe(false) - } - ) - - it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { - const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' - expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() - // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. - const chat = [ - '› is there an update available, and do my hooks need review?', - '✨ Update available! 0.158.0 -> 0.159.0', - 'Run npm install -g @openai/codex to update.', - ' gpt-6-astra default · ~/repo', - ' ← for agents · ? for shortcuts' - ].join('\n') - expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() - }) - - it('does not name a mid-session Codex popup a hooks review', () => { - // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. - const popup = [ - ' Use this reset?', - ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', - '› 2. No, go back Choose a different reset', - ' enter confirm · esc back' - ].join('\n') - expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') - }) - - describe('through the runtime', () => { - it.each(DIALOGS)( - '$name: stops a tui-idle wait as $reason instead of typing into it', - async ({ name, reason }) => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: readRuntimeFixture(name), - size: { cols: 120, rows: 40 } - }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) - ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) - }, - 15_000 - ) - }) -}) diff --git a/src/main/runtime/freebuff-status-transcript.test.ts b/src/main/runtime/freebuff-status-transcript.test.ts deleted file mode 100644 index 04aa1e0a2abd..000000000000 --- a/src/main/runtime/freebuff-status-transcript.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const transcript = readFileSync( - join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), - 'utf8' -) - -describe('Freebuff execution-host status', () => { - it('publishes real running, question, and settled screens into the canonical store', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - const states: string[] = [] - const questions: string[] = [] - try { - // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. - for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - const row = wiring.statusStore.getStatusSnapshot()[0] - if (row && states.at(-1) !== row.state) { - states.push(row.state) - } - if (row?.interactivePrompt) { - questions.push(row.interactivePrompt) - } - } - expect(states).toContain('working') - expect(states).toContain('waiting') - expect(states.at(-1)).toBe('done') - expect(questions.join('\n')).toMatch(/blue|green/i) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - it.each([ - { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, - { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } - ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols, rows } - }, - wiring.deps - ) - try { - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), - Date.now() - ) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ - state: 'blocked', - toolInput: input, - agentType: 'freebuff' - }) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - - it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - try { - for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), - Date.now() - ) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( - name === 'trust' ? 'blocked' : 'done' - ) - if (name === 'ready') { - expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ - sessionBoundary: true, - prompt: '' - }) - } - if (name === 'lifecycle') { - expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) - } - } - expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - - it('does not infer remote status from the client screen', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - connectionId: 'ssh-test', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - try { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) -}) diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index 946fd05b6c51..871371ee1999 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -24,7 +24,7 @@ export function buildHeadlessTerminalSplitLayout( direction: 'horizontal' | 'vertical' } ): TerminalLayoutSnapshot { - // Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit. + // Why: the stored tree may already hold the new leaf; it is placed again beside its source. const currentRoot = existing?.root ? removeLayoutLeaf(existing.root, args.leafId) : null const existingRoot: TerminalPaneLayoutNode = currentRoot ?? { type: 'leaf', diff --git a/src/main/runtime/hermes-readiness-transcript.test.ts b/src/main/runtime/hermes-readiness-transcript.test.ts deleted file mode 100644 index 5605f69f91ef..000000000000 --- a/src/main/runtime/hermes-readiness-transcript.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' -import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8') - -/** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the - * same normalizer the hook relay uses — so a regression in the event mapping fails here too, - * not only in the provider's own unit test. */ -const status = (eventName: string, payload: Record = {}): string => { - const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) - expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() - return `\x1b]9999;${JSON.stringify(parsed)}\x07` -} - -const readyPane = async () => - createTranscriptPane({ - paneTitle: 'Hermes Agent', - foregroundProcess: 'hermes', - launchAgent: 'hermes', - size: { cols: 120, rows: 31 }, - data: captured - }) - -describe('Hermes TUI readiness from a captured PTY', () => { - it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => { - const { runtime, handle } = await readyPane() - // What Orca's own managed plugin sends for `on_session_start`. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - const read = vi.spyOn(runtime, 'readTerminal') - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) - ).resolves.toMatchObject({ satisfied: true }) - // The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it. - expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) - }, 5_000) - - it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { - const { runtime } = await readyPane() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. - const internals = runtime as unknown as { - leaves: Map - issueHandle: (leaf: unknown) => string - } - const leaf = internals.leaves.values().next().value - expect(leaf).toBeDefined() - expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(internals.issueHandle(leaf), { - condition: 'tui-idle', - timeoutMs: 2_000 - }) - ).resolves.toMatchObject({ satisfied: true }) - }, 5_000) - - it('does not settle while a turn the user started is still running', async () => { - const { runtime, handle } = await readyPane() - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - // `pre_llm_call` — the first event that means a turn actually began. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('pre_llm_call', { user_message: 'hi' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - - it('does not settle on a turn-end done row, only on a session boundary', async () => { - const { runtime, handle } = await readyPane() - // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('post_llm_call', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - - it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => { - const { runtime, handle } = await readyPane() - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - // `pre_approval_request` supersedes the boundary row the session opened with. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('pre_approval_request', { command: 'rm -rf build' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) -}) diff --git a/src/main/runtime/launched-agent-composer-readiness-codex.test.ts b/src/main/runtime/launched-agent-composer-readiness-codex.test.ts deleted file mode 100644 index 1821d4c3a286..000000000000 --- a/src/main/runtime/launched-agent-composer-readiness-codex.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { readRuntimeFixture } from './agent-transcript-replay-test-harness' -import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex -// release that the launch wait cannot read ready reports "prompt wasn't sent". -async function launchedCodexPane(data: string) { - return createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data, - size: { cols: 120, rows: 40 } - }) -} - -async function waitForCodexComposer( - pane: Awaited>, - timeoutMs: number, - streamedData?: string -) { - // Pane creation uses real timers; only the readiness budget advances virtually. - vi.useFakeTimers({ - toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] - }) - try { - const ready = waitForLaunchedAgentComposer(pane.runtime, pane.handle, 'codex', timeoutMs) - void ready.catch(() => {}) - if (streamedData !== undefined) { - pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, streamedData, Date.now()) - } - await vi.advanceTimersByTimeAsync(timeoutMs + 1) - return await ready - } finally { - vi.useRealTimers() - } -} - -// Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every -// 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed -// whole it holds a starred composer still, which the live stream never does. -function greetingBeforeStarfield(): string { - const data = readRuntimeFixture('codex-0158-fresh-home-greeting') - const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/) - return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar)) -} - -describe('launch readiness for a freshly launched Codex', () => { - it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { - const data = greetingBeforeStarfield() - // Presence precondition: the cut keeps the live status row and drops every star. - expect(data).toContain('GPT-6-Astra default') - expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/) - const pane = await launchedCodexPane(data) - await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { - const pane = await launchedCodexPane(readRuntimeFixture('codex-0157-plain-ready')) - await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it.each([ - ['codex-0158-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], - ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], - ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] - ])( - '%s: stops at the dialog as %s instead of pasting into it', - async (fixture, reason) => { - const pane = await launchedCodexPane(readRuntimeFixture(fixture)) - await expect(waitForCodexComposer(pane, 2_500)).resolves.toMatchObject({ - satisfied: false, - blockedReason: reason - }) - }, - 15_000 - ) - - // Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the - // composer signal, so only the screen check keeps it from pasting; it hands the dialog to - // `tui-idle` to report, well inside the desktop paste's budget. - it.each([ - ['codex-0157-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], - ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], - ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] - ])( - '%s streamed in after the launch: reports the dialog as %s, never as the composer', - async (fixture, reason) => { - const data = readRuntimeFixture(fixture) - // Presence precondition: the dialog draws Codex's composer glyph after bracketed paste. - expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›') - const pane = await launchedCodexPane('') - await expect(waitForCodexComposer(pane, 2_500, data)).resolves.toMatchObject({ - satisfied: false, - blockedReason: reason - }) - }, - 15_000 - ) - - // Why: 0.157 discards input typed behind its provisional `model: loading` screen. - it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => { - const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install') - const install = full.indexOf('Installing daemon') - // Presence precondition: the cut keeps the provisional header and stops before the live chat. - expect(install).toBeGreaterThan(0) - const provisional = full.slice(0, full.indexOf('\n', install) + 1) - expect(provisional).toMatch(/model:.*loading/) - const pane = await launchedCodexPane(provisional) - await expect(waitForCodexComposer(pane, 5_000)).rejects.toThrow(/timeout/) - }, 15_000) -}) diff --git a/src/main/runtime/launched-agent-worker-start-grok.test.ts b/src/main/runtime/launched-agent-worker-start-grok.test.ts deleted file mode 100644 index cde8cb8d2cd0..000000000000 --- a/src/main/runtime/launched-agent-worker-start-grok.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * A Grok worker start replayed through the runtime at its recorded read times. Grok draws its - * composer glyph at 0.6 s and then shimmers its logo until 9.9 s, and its only title is its bare - * name, so a wait that holds that title to quiet output answers ten seconds after the composer. - */ - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { waitForWorkerStartComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const COMPOSER_FRAME_MS = GROK_STARTUP_PTY_TRACE.find((chunk) => chunk.data?.includes('❯'))?.t -const LAST_FRAME_MS = GROK_STARTUP_PTY_TRACE.at(-1)?.t ?? 0 - -async function replayGrokWorkerStart(): Promise { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'grok', - launchAgent: 'grok', - size: { cols: 120, rows: 30 }, - data: '' - }) - vi.useFakeTimers() - const startedAt = Date.now() - let settledAt: number | null = null - void waitForWorkerStartComposer(runtime, handle, 'grok', 60_000).then( - (wait) => { - settledAt = wait.satisfied ? Date.now() - startedAt : null - }, - () => {} - ) - for (const chunk of GROK_STARTUP_PTY_TRACE) { - await vi.advanceTimersByTimeAsync(Math.max(0, startedAt + chunk.t - Date.now())) - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - chunk.data ?? 'x'.repeat(chunk.bytes ?? 0), - Date.now() - ) - } - await vi.advanceTimersByTimeAsync(5_000) - return settledAt -} - -describe('a Grok worker start', () => { - afterEach(() => vi.useRealTimers()) - - it('is ready on its composer glyph, not once its logo stops animating', async () => { - expect(COMPOSER_FRAME_MS).toBeLessThan(1_000) - const settledAt = await replayGrokWorkerStart() - expect(settledAt).not.toBeNull() - expect(settledAt).toBeGreaterThanOrEqual(COMPOSER_FRAME_MS ?? 0) - // Within a second of the glyph: main's own wait answered on the title at once (2.2-2.8 s live). - expect(settledAt).toBeLessThan((COMPOSER_FRAME_MS ?? 0) + 1_000) - expect(settledAt).toBeLessThan(LAST_FRAME_MS) - }) -}) diff --git a/src/main/runtime/muse-readiness-transcript.test.ts b/src/main/runtime/muse-readiness-transcript.test.ts deleted file mode 100644 index 705eda71df57..000000000000 --- a/src/main/runtime/muse-readiness-transcript.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { RuntimeMachineName } from './runtime-machine-name' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -let machineNameStartSpy: { mockRestore(): void } | undefined - -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) -}) - -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined -}) - -describe('Muse readiness from captured terminal bytes', () => { - it('recognizes a ready folder workspace without a skills summary', async () => { - const data = readFileSync( - join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), - 'utf8' - ) - expect(data).toContain(String.fromCharCode(27)) - expect(data).not.toContain('Skills:') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'muse-first-class-workspace', - foregroundProcess: 'muse-bin-1.3.0-R3401.1', - launchAgent: 'muse', - data - }) - await runtime.readTerminal(handle, { screen: true }) - await expect(waitForTranscriptIdle({ runtime, handle }, 10_000)).resolves.toMatchObject({ - satisfied: true - }) - }, 15_000) -}) diff --git a/src/main/runtime/omp-readiness-transcripts.test.ts b/src/main/runtime/omp-readiness-transcripts.test.ts index ecfd65e7cc30..6cab4aa1fdec 100644 --- a/src/main/runtime/omp-readiness-transcripts.test.ts +++ b/src/main/runtime/omp-readiness-transcripts.test.ts @@ -4,6 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' import { extractLastOscTitle } from '../../shared/osc-title-extraction' import type { TuiAgent } from '../../shared/tui-agent' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' +import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -12,220 +17,519 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function transcript(name: string): string { - return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') -} - -// Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture -// exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or -// differs. -const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' -const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` - -async function waitsReady( - options: { - name: string - title: string - launchAgent?: TuiAgent - repaint?: string - size?: { cols: number; rows: number } | null - busyFirst?: boolean - keepalive?: boolean - }, - timeoutMs = 5_000 -): Promise { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: options.launchAgent ? 'omp' : 'bun', - data: transcript(options.name), - ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), - ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) - }) - if (options.busyFirst) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) - } - if (options.repaint) { - // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) - await vi.advanceTimersByTimeAsync(50) - } - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) - // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, - // as xterm and main's query authority do; the capture tool answered nothing. - const keepalive = options.keepalive - ? setInterval(() => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), 1_000) - : null - const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( - (result) => result.satisfied === true, - () => false - ) - await vi.advanceTimersByTimeAsync(timeoutMs) - if (keepalive) { - clearInterval(keepalive) +describe('OMP captured composer and title readiness', () => { + function transcript(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } - return settled -} - -describe('OMP 18.4.5 captured readiness', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) - - it.each([ - ['omp-18-setup', 120, 40, false], - ['omp-18-composer', 120, 40, true], - ['omp-18-composer-narrow', 60, 24, true] - ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { - const data = transcript(name) - expect(data).toContain('\x1b[') - expect(data).toContain('\r') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: extractLastOscTitle(data) ?? 'OMP', - foregroundProcess: 'omp', - data, - launchAgent: 'omp', - size: { cols, rows } - }) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = ready - ? expect(result).resolves.toMatchObject({ satisfied: true }) - : expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - }) - it('does not accept the composer while its native title still says working', async () => { + // Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture + // exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or + // differs. + const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' + const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` + + async function waitsReady( + options: { + name: string + title: string + launchAgent?: TuiAgent + repaint?: string + size?: { cols: number; rows: number } | null + busyFirst?: boolean + keepalive?: boolean + }, + timeoutMs = 5_000 + ): Promise { const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'OMP', - foregroundProcess: 'omp', - data: transcript('omp-18-composer'), - launchAgent: 'omp', - size: { cols: 120, rows: 40 } - }) - // Synthetic busy transition isolates the timer while preserving the captured composer grid. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) - const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) - await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) - }) + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ? 'omp' : 'bun', + data: transcript(options.name), + ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), + ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) + }) + if (options.busyFirst) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + } + if (options.repaint) { + // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) + await vi.advanceTimersByTimeAsync(50) + } + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) + // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, + // as xterm and main's query authority do; the capture tool answered nothing. + const keepalive = options.keepalive + ? setInterval( + () => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), + 1_000 + ) + : null + const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( + (result) => result.satisfied === true, + () => false + ) + await vi.advanceTimersByTimeAsync(timeoutMs) + if (keepalive) { + clearInterval(keepalive) + } + return settled + } - it.each([ - ['hintless', HINTLESS_COMPOSER_ROW], - ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] - ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π > capture-cwd', + describe('OMP 18.4.5 captured readiness', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it.each([ + ['omp-18-setup', 120, 40, false], + ['omp-18-composer', 120, 40, true], + ['omp-18-composer-narrow', 60, 24, true] + ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { + const data = transcript(name) + expect(data).toContain('\x1b[') + expect(data).toContain('\r') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: extractLastOscTitle(data) ?? 'OMP', + foregroundProcess: 'omp', + data, launchAgent: 'omp', - repaint + size: { cols, rows } }) - ).toBe(true) - }) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = ready + ? expect(result).resolves.toMatchObject({ satisfied: true }) + : expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) - it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', + it('does not accept the composer while its native title still says working', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'OMP', + foregroundProcess: 'omp', + data: transcript('omp-18-composer'), launchAgent: 'omp', - repaint: HINTLESS_COMPOSER_ROW, - busyFirst: true + size: { cols: 120, rows: 40 } }) - ).toBe(true) - }) - - it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { - expect( - await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) - ).toBe(true) - }) - - it("refuses the wizard's splash, which has no step heading yet", async () => { - const setup = transcript('omp-18-setup') - const splash = setup.slice(0, setup.indexOf('Setup step')) - expect(splash).toContain('\x1b[?1049h') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'omp', - data: splash, - launchAgent: 'omp', - size: { cols: 120, rows: 40 } - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - }) + // Synthetic busy transition isolates the timer while preserving the captured composer grid. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) + await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) + }) - it.each([ - ['omp-18-composer', true], - ['omp-18-setup', false] - ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { - expect( - await waitsReady( - { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, - 10_000 - ) - ).toBe(ready) - }) + it.each([ + ['hintless', HINTLESS_COMPOSER_ROW], + ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] + ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + repaint + }) + ).toBe(true) + }) - it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', - launchAgent: 'omp', - repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', - busyFirst: true - }) - ).toBe(true) - }) + it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: HINTLESS_COMPOSER_ROW, + busyFirst: true + }) + ).toBe(true) + }) - it.each(['π - capture-cwd', 'π: capture-cwd'])( - 'refuses %s while the setup wizard is on screen', - async (title) => { - expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) - } - ) + it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { + expect( + await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) + ).toBe(true) + }) - it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π > capture-cwd', - launchAgent: 'omp', - size: null - }) - ).toBe(false) - // A post-turn title cannot come from setup, so it needs no screen. - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', + it("refuses the wizard's splash, which has no step heading yet", async () => { + const setup = transcript('omp-18-setup') + const splash = setup.slice(0, setup.indexOf('Setup step')) + expect(splash).toContain('\x1b[?1049h') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'omp', + data: splash, launchAgent: 'omp', - size: null + size: { cols: 120, rows: 40 } }) - ).toBe(true) - }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) - it.each([['pi' as const], [undefined]])( - 'does not make the setup wizard ready at once for agent identity %s', - async (launchAgent) => { - // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. + it.each([ + ['omp-18-composer', true], + ['omp-18-setup', false] + ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { expect( await waitsReady( - { - name: 'omp-18-setup', - title: 'π > capture-cwd', - ...(launchAgent ? { launchAgent } : {}) - }, - 1_000 + { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, + 10_000 ) + ).toBe(ready) + }) + + it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', + busyFirst: true + }) + ).toBe(true) + }) + + it.each(['π - capture-cwd', 'π: capture-cwd'])( + 'refuses %s while the setup wizard is on screen', + async (title) => { + expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) + } + ) + + it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + size: null + }) ).toBe(false) + // A post-turn title cannot come from setup, so it needs no screen. + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(true) + }) + + it.each([['pi' as const], [undefined]])( + 'does not make the setup wizard ready at once for agent identity %s', + async (launchAgent) => { + // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. + expect( + await waitsReady( + { + name: 'omp-18-setup', + title: 'π > capture-cwd', + ...(launchAgent ? { launchAgent } : {}) + }, + 1_000 + ) + ).toBe(false) + } + ) + }) +}) + +describe('OpenCode captured worker composer', () => { + /** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + + // Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title + // and the launcher's cursor toggle are synthetic. + const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + const OPENCODE_PLACEHOLDER = 'Ask anything' + const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + + const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], + ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] + ] + const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) + const AGENT_ROW = /\u00b7 \S/ + const BOX_BOTTOM_LEFT = '\u2579' + + /** The read that ends the synchronized update drawing OpenCode's input box. */ + function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) + } + + /** The first read whose screen shows `· ` on the box's last line, above its corner. */ + async function agentRowRead(name: string, chunks: string[]): Promise { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') + ) + let read = 0 + for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { + const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) + if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { + return read + } + read += 1 } + return -1 + } + + async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { + composer: number | null + composerMs: number | null + tuiIdle: number | null + } = { composer: null, composerMs: null, tuiIdle: null } + let read = -1 + const startedAt = Date.now() + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => { + settledAt.composer ??= read + settledAt.composerMs ??= Date.now() - startedAt + }) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { + settledAt, + composer, + boxRead: boxRead(chunks), + agentRowRead: await agentRowRead(name, chunks) + } + } + + describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles once the box and the agent row are both painted', + async (name, agent) => { + const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. + expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) + } + ) + + it.each(OPENCODE_1_RUNS)( + '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it('never settles on the box while a slow agent list leaves the row unpainted', async () => { + const { settledAt, boxRead } = await replay( + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode2' + ) + expect(settledAt.composer).toBeGreaterThan(boxRead) + }) + + it('takes the box after the grace in a pane too narrow for the agent row', async () => { + const name = 'opencode-2-0-21-timed-narrow-pane' + const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) + const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] + const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') + expect(agentRowRead).toBe(-1) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // Fake timers drop the recorded times' fraction of a millisecond. + expect( + Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) + ).toBeLessThan(1) + // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. + expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) + }) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) + }) +}) + +describe('Freebuff captured canonical status delivery', () => { + const transcript = readFileSync( + join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), + 'utf8' ) + + describe('Freebuff execution-host status', () => { + it('publishes real running, question, and settled screens into the canonical store', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + const states: string[] = [] + const questions: string[] = [] + try { + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + const row = wiring.statusStore.getStatusSnapshot()[0] + if (row && states.at(-1) !== row.state) { + states.push(row.state) + } + if (row?.interactivePrompt) { + questions.push(row.interactivePrompt) + } + } + expect(states).toContain('working') + expect(states).toContain('waiting') + expect(states.at(-1)).toBe('done') + expect(questions.join('\n')).toMatch(/blue|green/i) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + it.each([ + { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, + { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } + ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols, rows } + }, + wiring.deps + ) + try { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + state: 'blocked', + toolInput: input, + agentType: 'freebuff' + }) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( + name === 'trust' ? 'blocked' : 'done' + ) + if (name === 'ready') { + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + sessionBoundary: true, + prompt: '' + }) + } + if (name === 'lifecycle') { + expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) + } + } + expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('does not infer remote status from the client screen', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + connectionId: 'ssh-test', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + }) }) diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts deleted file mode 100644 index e49254defddb..000000000000 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first - * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), - * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. - */ - -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' -import type { TuiAgent } from '../../shared/tui-agent' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' -import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title -// and the launcher's cursor toggle are synthetic. -const ZSH_LAUNCH = - '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' -const OPENCODE_PLACEHOLDER = 'Ask anything' -const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' - -const RUNS: [string, TuiAgent][] = [ - ['opencode-1-18-32-timed-boot-slow', 'opencode'], - ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], - ['opencode-1-18-32-timed-first-launch', 'opencode'], - ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], - ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], - ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], - ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], - ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], - ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] -] -const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) -const AGENT_ROW = /\u00b7 \S/ -const BOX_BOTTOM_LEFT = '\u2579' - -/** The read that ends the synchronized update drawing OpenCode's input box. */ -function boxRead(chunks: string[]): number { - const data = chunks.join('') - const boxEnd = - data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + - SYNCHRONIZED_UPDATE_END.length - let end = 0 - return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) -} - -/** The first read whose screen shows `· ` on the box's last line, above its corner. */ -async function agentRowRead(name: string, chunks: string[]): Promise { - const meta: { cols: number; rows: number } = JSON.parse( - readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') - ) - let read = 0 - for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { - const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) - if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { - return read - } - read += 1 - } - return -1 -} - -async function replay(name: string, agent: TuiAgent) { - const { chunks, times } = readTimedRuntimeFixture(name) - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'opencode', - launchAgent: agent, - size: { cols: 120, rows: 40 }, - data: '' - }) - vi.useFakeTimers() - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) - const settledAt: { composer: number | null; composerMs: number | null; tuiIdle: number | null } = - { composer: null, composerMs: null, tuiIdle: null } - let read = -1 - const startedAt = Date.now() - const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) - void composer.then(() => { - settledAt.composer ??= read - settledAt.composerMs ??= Date.now() - startedAt - }) - const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - void tuiIdle.then( - () => (settledAt.tuiIdle ??= read), - () => {} - ) - let now = 0 - await vi.advanceTimersByTimeAsync(0) - for (const [index, chunk] of chunks.entries()) { - await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) - now = times[index] - read = index - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) - await vi.advanceTimersByTimeAsync(0) - } - return { - settledAt, - composer, - boxRead: boxRead(chunks), - agentRowRead: await agentRowRead(name, chunks) - } -} - -describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it.each(RUNS)( - '%s: the worker-start lane settles once the box and the agent row are both painted', - async (name, agent) => { - const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) - await expect(composer).resolves.toMatchObject({ satisfied: true }) - // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. - expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) - } - ) - - it.each(OPENCODE_1_RUNS)( - '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', - async (name, agent) => { - const { settledAt, boxRead } = await replay(name, agent) - expect(settledAt.composer).toBe(boxRead) - } - ) - - it('never settles on the box while a slow agent list leaves the row unpainted', async () => { - const { settledAt, boxRead } = await replay( - 'opencode-2-0-21-timed-natural-load-enter-dropped', - 'opencode2' - ) - expect(settledAt.composer).toBeGreaterThan(boxRead) - }) - - it('takes the box after the grace in a pane too narrow for the agent row', async () => { - const name = 'opencode-2-0-21-timed-narrow-pane' - const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) - const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') - const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] - const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') - expect(agentRowRead).toBe(-1) - await expect(composer).resolves.toMatchObject({ satisfied: true }) - // Fake timers drop the recorded times' fraction of a millisecond. - expect( - Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) - ).toBeLessThan(1) - // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. - expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) - }) - - it.each(RUNS)( - '%s: the bare-name tui-idle wait main used would have settled before the box', - async (name, agent) => { - const { settledAt, boxRead } = await replay(name, agent) - expect(settledAt.tuiIdle).not.toBeNull() - expect(settledAt.tuiIdle!).toBeLessThan(boxRead) - } - ) -}) diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index 055e64c4622f..a9f46fca1dab 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -18,7 +18,6 @@ import { buildHeadlessMobileSessionTabGroups, cloneTerminalLayoutSnapshot } from './mobile-session-layout-projection' -import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout' export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh { protected applyMobileSessionTabNavigation( @@ -171,38 +170,6 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW this.emitMobileSessionTabsSnapshot(nextSnapshot) } - // Why: a headless split only updated the LIVE session snapshot, never the - // persisted workspace session layout. So a later snapshot rebuild (e.g. on the - // next terminal create) re-derived from the stale single-leaf persisted layout - // and collapsed the split. Persist the new split leaf into the workspace - // session's terminalLayoutsByTabId so the split survives rebuilds. - protected persistHeadlessTerminalSplit(args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - splitFromLeafId: string - direction: 'horizontal' | 'vertical' - }): boolean { - const session = this.getWorkspaceSessionForWorktree(args.worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return false - } - const existing = session.terminalLayoutsByTabId?.[args.tabId] - const nextLayout = buildHeadlessTerminalSplitLayout( - existing ? cloneTerminalLayoutSnapshot(existing) : undefined, - args - ) - this.setWorkspaceSessionForWorktree(args.worktreeId, { - ...session, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: nextLayout - } - }) - return true - } - protected persistHeadlessTerminalActiveLeaf( worktreeId: string, tab: RuntimeMobileSessionTerminalTab diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 301645aad303..99d11f4a5739 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -259,29 +259,6 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu } } - protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return - } - const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) - const tabs = session.tabsByWorktree[worktreeId] ?? [] - const reordered = [...tabs] - .sort((a, b) => { - const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER - const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER - return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt - }) - .map((tab, index) => ({ ...tab, sortOrder: index })) - this.setWorkspaceSessionForWorktree(worktreeId, { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [worktreeId]: reordered - } - }) - } - protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { if (this.mobileSessionTabListeners.size === 0) { return diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index 179d796d0e41..aecc996d6c24 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -16,6 +16,12 @@ import { } from './runtime-managed-worktree-metadata' import { resolveRuntimeGitHubWorktreeBase } from './runtime-github-worktree-base' import { resolveRuntimeGitLabWorktreeBase } from './runtime-gitlab-worktree-base' +import { listWorktreesStrict } from '../git/worktree-listing' +import { + adoptRuntimeProvisionedRoot, + readLocalSparseCheckoutEnabled, + type RuntimeProvisionedRootRequest +} from './runtime-provisioned-root-adoption' export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithCreateManagedWorktree { protected createManagedRemoteWorktree( @@ -171,6 +177,27 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC }) } + adoptManagedProvisionedRoot( + repoSelector: string, + request: RuntimeProvisionedRootRequest + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return adoptRuntimeProvisionedRoot(repoSelector, request, { + store: this.store, + resolveRepo: (selector) => this.resolveRepoSelector(selector), + listWorktrees: (repoPath) => listWorktreesStrict(repoPath), + isSparseCheckoutEnabled: readLocalSparseCheckoutEnabled, + invalidateScan: (repoId) => { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + }, + notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId), + showWorktree: (selector) => this.showManagedWorktree(selector) + }) + } + persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { if (!this.store) { throw new Error('runtime_unavailable') @@ -179,7 +206,8 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC orderedIds, store: this.store, invalidateResolved: () => this.invalidateResolvedWorktreeCache(), - notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId) + notifyChanged: (repoId) => + this.notifyWorktreesChanged(repoId, { invalidateGitScans: false, sortOrderOnly: true }) }) } diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 69931da960b2..0aba7f97758b 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -137,6 +137,10 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork timing }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) + if (recordedLineage.lineage || recordedLineage.workspaceLineage) { + // Lineage lands after the create notify; order-save events no longer refetch it. + this.notifyWorktreesChanged(repo.id, { invalidateGitScans: false }) + } this.emitWorktreeLifecycle({ kind: 'created', worktreeId: result.worktree.id, @@ -152,13 +156,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork lineage: recordedLineage.lineage, workspaceLineage: recordedLineage.workspaceLineage }, - ...(lineageInput - ? { - lineage: recordedLineage.lineage, - workspaceLineage: recordedLineage.workspaceLineage, - warnings: recordedLineage.warnings - } - : {}) + ...(lineageInput ? recordedLineage : {}) } } const localCreate = await createRuntimeLocalManagedWorktree({ diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index dac1c0e199d5..07375833d7f0 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -111,6 +111,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + ...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}), startupCommandDelivery: opts.startupCommandDelivery, ...(opts.identity ? { @@ -181,10 +182,6 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() - if (opts.viewMode) { - // Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default. - this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) - } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 3be610ed18fb..97fdc3de949d 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -29,7 +29,8 @@ export { getTerminalViewerColors, setPairedViewerColors } from './terminal-view- export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors' export type { RuntimePtyController } from './runtime-pty-controller-contract' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' -export { runtimeNewTabPlacement } from './runtime-terminal-spawn-placement' +export { newTabIn } from './runtime-terminal-spawn-placement' +export { spawnInAdmittedPane } from './runtime-pane-admission' export type { IpcMainEvent } from 'electron' // Why initiallyHidden: no renderer pane exists yet, so main must answer startup queries — Muse diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index dcc80914b6f4..03d7583e6c84 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -123,7 +123,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate try { const { launchAgent } = launchOpts launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent }) - result = await this.ptyController.spawn({ + result = await dependencies.spawnInAdmittedPane(this.store, this.ptyController, { cols: 120, rows: 40, cwd, @@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), - placement: dependencies.runtimeNewTabPlacement(), + ...dependencies.newTabIn(launchOpts), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts deleted file mode 100644 index add68726e79d..000000000000 --- a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { createSshDisposalError } from '../ssh/ssh-channel-multiplexer' -import { createSshFileExplorerWatchTestConnection } from './ssh-file-explorer-watch-test-connection' - -const { - resolveAuthorizedPathMock, - statMock, - watchInWatcherProcessMock, - closeWatcherInWatcherProcessMock, - getSshFilesystemProviderMock, - providerRegistrationListeners -} = vi.hoisted(() => ({ - resolveAuthorizedPathMock: vi.fn(), - statMock: vi.fn(), - watchInWatcherProcessMock: vi.fn(), - closeWatcherInWatcherProcessMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - providerRegistrationListeners: new Set<(connectionId: string) => void>() -})) - -vi.mock('fs/promises', async () => { - const actual = await vi.importActual>('fs/promises') - return { ...actual, stat: statMock } -}) -vi.mock('./file-watcher-host', () => ({ - closeFileExplorerWatcherInWatcherProcess: closeWatcherInWatcherProcessMock, - watchFileExplorerInWatcherProcess: watchInWatcherProcessMock -})) -vi.mock('../ipc/filesystem-auth', async () => { - const actual = await vi.importActual>('../ipc/filesystem-auth') - return { ...actual, resolveAuthorizedPath: resolveAuthorizedPathMock } -}) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.', - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - providerRegistrationListeners.add(listener) - return () => providerRegistrationListeners.delete(listener) - } -})) - -import { - _resetRuntimeFileWatcherLeasesForTests, - awaitRuntimeFileWatcherUnsubscribes, - RuntimeFileCommands -} from './orca-runtime-files' - -const ROOT_PATH = '/home/me/repo' -const CONNECTION_ID = 'conn-1' -const OVERFLOW_EVENTS: FsChangeEvent[] = [{ kind: 'overflow', absolutePath: ROOT_PATH }] -const connections: ReturnType[] = [] -const releases: (() => Promise)[] = [] - -/** Drive the provider-registration hook the way a relay reconnect would. */ -function emitProviderRegistered(connectionId: string): void { - for (const listener of providerRegistrationListeners) { - listener(connectionId) - } -} - -function createRuntimeFileCommands(): RuntimeFileCommands { - return new RuntimeFileCommands({ - getRuntimeId: () => 'runtime-1', - requireStore: () => ({ getRepo: vi.fn(() => undefined) }), - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH })), - resolveRuntimeFileTarget: vi.fn(async () => ({ - worktree: { id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH }, - executionHostId: `ssh:${CONNECTION_ID}` - })), - resolveRuntimeGitTarget: vi.fn(), - openFile: vi.fn() - } as never) -} - -describe('remote file-explorer watch re-arm', () => { - beforeEach(() => { - resolveAuthorizedPathMock.mockReset() - statMock.mockReset() - watchInWatcherProcessMock.mockReset() - closeWatcherInWatcherProcessMock.mockReset() - getSshFilesystemProviderMock.mockReset() - providerRegistrationListeners.clear() - }) - - afterEach(async () => { - for (const { mux } of connections) { - mux.dispose() - } - for (const release of releases.splice(0)) { - await release() - } - await awaitRuntimeFileWatcherUnsubscribes() - _resetRuntimeFileWatcherLeasesForTests() - for (const { provider } of connections.splice(0)) { - provider.dispose() - } - }) - - function connect() { - const connection = createSshFileExplorerWatchTestConnection(ROOT_PATH, CONNECTION_ID) - connections.push(connection) - return connection - } - - async function startInitialWatch() { - const first = connect() - getSshFilesystemProviderMock.mockReturnValue(first.provider) - const watch = vi.spyOn(first.provider, 'watch') - const commands = createRuntimeFileCommands() - const onEvents = vi.fn() - const controller = new AbortController() - const onTerminalError = vi.fn(() => controller.abort()) - const setup = commands.watchFileExplorer( - 'id:wt-1', - onEvents, - onTerminalError, - controller.signal - ) - await vi.waitFor(() => expect(first.countRequests('fs.watch')).toBe(1)) - const finish = async () => { - first.settleWatch() - const release = await setup - releases.push(release) - return release - } - return { first, watch, onEvents, onTerminalError, controller, setup, finish } - } - - it.each(['event', 'terminal error'] as const)( - 'fences the initial provider %s while replacement setup is pending', - async (stage) => { - const { first, onEvents, onTerminalError, controller, finish } = await startInitialWatch() - const release = await finish() - const next = connect() - getSshFilesystemProviderMock.mockReturnValue(next.provider) - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(next.countRequests('fs.watch')).toBe(1)) - if (stage === 'event') { - first.emitChange() - } else { - first.failWatch('late initial failure') - } - expect(onEvents).not.toHaveBeenCalled() - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - next.settleWatch() - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledExactlyOnceWith(OVERFLOW_EVENTS)) - next.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - await release() - expect(next.countRequests('fs.unwatch')).toBe(1) - } - ) - - it('fences initial callbacks after same-provider replacement and unsubscribe', async () => { - const { first, watch, onEvents, onTerminalError, finish } = await startInitialWatch() - const release = await finish() - const initialEvent = watch.mock.calls[0]?.[1] - const initialError = watch.mock.calls[0]?.[2]?.onTerminalError - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledExactlyOnceWith(OVERFLOW_EVENTS)) - initialEvent?.([{ kind: 'update', absolutePath: `${ROOT_PATH}/stale.ts` }]) - initialError?.(new Error('old generation failed')) - expect(onEvents).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - first.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - await release() - initialEvent?.([{ kind: 'update', absolutePath: `${ROOT_PATH}/closed.ts` }]) - initialError?.(new Error('closed initial watch failed')) - expect(onEvents).toHaveBeenCalledTimes(2) - expect(onTerminalError).not.toHaveBeenCalled() - expect(first.countRequests('fs.unwatch')).toBe(1) - }) - - it('delivers current initial events during setup and genuine current failures after setup', async () => { - const { first, onEvents, onTerminalError, controller, finish } = await startInitialWatch() - first.emitChange() - expect(onEvents).toHaveBeenCalledOnce() - await finish() - first.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - first.failWatch('current initial failure') - expect(onTerminalError).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ message: 'current initial failure' }) - ) - expect(controller.signal.aborted).toBe(true) - }) - - it('keeps a completed initial watch armed after typed connection loss', async () => { - const { watch, onEvents, onTerminalError, controller, finish } = await startInitialWatch() - await finish() - watch.mock.calls[0]?.[2]?.onTerminalError?.(createSshDisposalError('connection_lost')) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - const next = connect() - getSshFilesystemProviderMock.mockReturnValue(next.provider) - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(next.countRequests('fs.watch')).toBe(1)) - next.settleWatch() - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledExactlyOnceWith(OVERFLOW_EVENTS)) - }) - - it.each(['connection loss', 'cancellation'] as const)( - 'rejects initial setup on %s and fences callbacks after setup ends', - async (reason) => { - const { first, watch, onEvents, onTerminalError, controller, setup } = - await startInitialWatch() - if (reason === 'connection loss') { - first.mux.dispose('connection_lost') - await expect(setup).rejects.toMatchObject({ code: 'CONNECTION_LOST' }) - } else { - controller.abort() - await expect(setup).rejects.toMatchObject({ name: 'AbortError' }) - } - watch.mock.calls[0]?.[1]([{ kind: 'update', absolutePath: `${ROOT_PATH}/late.ts` }]) - watch.mock.calls[0]?.[2]?.onTerminalError?.(new Error('late setup failure')) - expect(onEvents).not.toHaveBeenCalled() - expect(onTerminalError).not.toHaveBeenCalled() - expect(providerRegistrationListeners.size).toBe(0) - } - ) - - it('reinstalls and resyncs when the connection re-registers its provider', async () => { - // Why: dispose() on transport loss stops the registration without firing onTerminalError, so - // nothing else tells this watch it died. - const firstUnwatch = vi.fn() - const secondUnwatch = vi.fn() - const firstProvider = { watch: vi.fn().mockResolvedValue(firstUnwatch) } - const secondProvider = { watch: vi.fn().mockResolvedValue(secondUnwatch) } - getSshFilesystemProviderMock.mockReturnValue(firstProvider) - const commands = createRuntimeFileCommands() - const onEvents = vi.fn() - - const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) - expect(firstProvider.watch).toHaveBeenCalledOnce() - - getSshFilesystemProviderMock.mockReturnValue(secondProvider) - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(secondProvider.watch).toHaveBeenCalledOnce()) - - expect(onEvents).toHaveBeenCalledWith(OVERFLOW_EVENTS) - // The dead transport's handle must not be closed against the fresh registration. - expect(firstUnwatch).not.toHaveBeenCalled() - await unsubscribe() - expect(secondUnwatch).toHaveBeenCalledOnce() - expect(firstUnwatch).not.toHaveBeenCalled() - }) - - it('ignores registrations for other connections', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - await commands.watchFileExplorer('id:wt-1', vi.fn()) - emitProviderRegistered('conn-other') - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) - - it('stops re-arming after the watch is released', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) - await unsubscribe() - emitProviderRegistered(CONNECTION_ID) - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) - - it('stops re-arming after the worktree is removed', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - await commands.watchFileExplorer('id:wt-1', vi.fn()) - commands.forgetFileExplorerWatchersAfterRemoval(ROOT_PATH, CONNECTION_ID) - emitProviderRegistered(CONNECTION_ID) - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index d962ae46ce98..2fd13bf610aa 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -267,12 +267,24 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { return { tabId, worktreeId: pty.worktreeId } } - protected notifyWorktreesChanged(repoId: string): void { - // Why here: the listing re-runs a scan this generation overtook, and a headless host has no - // window notifier to bump it, so the runtime's own change event bumps before it is sent. - runWorktreeChangeInvalidators(repoId) - this.notifier?.worktreesChanged(repoId) - this.emitClientEvent({ type: 'worktreesChanged', repoId }) + protected notifyWorktreesChanged( + repoId: string, + options?: { invalidateGitScans?: boolean; sortOrderOnly?: boolean } + ): void { + // Headless hosts invalidate here unless only metadata changed. + if (options?.invalidateGitScans !== false) { + runWorktreeChangeInvalidators(repoId) + } + if (options) { + this.notifier?.worktreesChanged(repoId, undefined, options) + } else { + this.notifier?.worktreesChanged(repoId) + } + this.emitClientEvent({ + type: 'worktreesChanged', + repoId, + ...(options?.sortOrderOnly === true ? { sortOrderOnly: true } : {}) + }) } /** Detail-level worktree lifecycle tap (plugin event bus). The coarse diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.test.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.test.ts new file mode 100644 index 000000000000..92c8c7e08133 --- /dev/null +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { + RuntimeMobileSessionTabMove, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' + +function terminalTab(parentTabId: string, isActive: boolean) { + return { + type: 'terminal' as const, + id: `${parentTabId}::leaf-1`, + parentTabId, + leafId: 'leaf-1', + title: 'Terminal', + isActive + } +} + +// No notifier, so moves take the headless (serve) path. +function headlessRuntime(): { + runtime: OrcaRuntimeService + snapshots: Map +} { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the published snapshot map is protected; seeding it stands in for a prior publication. + const internals = runtime as unknown as { + mobileSessionTabsByWorktree: Map + } + const snapshots = internals.mobileSessionTabsByWorktree + snapshots.set('wt-1', { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-a', + activeTabId: 'tab-a::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { id: 'group-a', activeTabId: 'tab-a', tabOrder: ['tab-a', 'tab-b'] }, + { id: 'group-c', activeTabId: 'tab-c', tabOrder: ['tab-c'] } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-c' } + }, + tabs: [terminalTab('tab-a', true), terminalTab('tab-b', false), terminalTab('tab-c', false)] + }) + return { runtime, snapshots } +} + +async function expectNoOp(move: RuntimeMobileSessionTabMove): Promise { + const { runtime, snapshots } = headlessRuntime() + const before = snapshots.get('wt-1') + await expect(runtime.moveMobileSessionTab('id:wt-1', move)).resolves.toEqual({ moved: true }) + expect(snapshots.get('wt-1')).toBe(before) +} + +describe('headless session tab moves', () => { + it('refuses a split onto a group the host does not have', async () => { + const { runtime, snapshots } = headlessRuntime() + const before = snapshots.get('wt-1') + await expect( + runtime.moveMobileSessionTab('id:wt-1', { + kind: 'split', + tabId: 'tab-b', + targetGroupId: 'group-missing', + splitDirection: 'right' + }) + ).rejects.toThrow('target_group_not_found') + expect(snapshots.get('wt-1')).toBe(before) + }) + + it('refuses a move into a group the host does not have', async () => { + const { runtime, snapshots } = headlessRuntime() + const before = snapshots.get('wt-1') + await expect( + runtime.moveMobileSessionTab('id:wt-1', { + kind: 'move-to-group', + tabId: 'tab-b', + targetGroupId: 'group-missing' + }) + ).rejects.toThrow('target_group_not_found') + expect(snapshots.get('wt-1')).toBe(before) + }) + + it('keeps a same-group drop a successful no-op', async () => { + await expectNoOp({ kind: 'move-to-group', tabId: 'tab-b', targetGroupId: 'group-a' }) + }) + + it('keeps splitting the last tab off its own group a successful no-op', async () => { + await expectNoOp({ + kind: 'split', + tabId: 'tab-c', + targetGroupId: 'group-c', + splitDirection: 'right' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts index 8caa6bbd9de4..baa75a1b6f5b 100644 --- a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts @@ -33,11 +33,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith if (!hostTabId) { throw new Error('tab_not_found') } - const publicSnapshot = this.toMobileSessionTabsResult(snapshot) - const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) - if (!targetGroup) { - throw new Error('target_group_not_found') - } + const targetGroup = this.requireHeadlessTargetGroup(snapshot, move.targetGroupId) const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) const orderIndexByParentTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) const nextTabs = [...snapshot.tabs].sort((a, b) => { @@ -69,10 +65,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith tabGroups: nextGroups, tabs: nextTabs } - this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder) - if (nextGroups.length > 1 && snapshot.tabGroupLayout) { - this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) - } + this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } @@ -90,6 +83,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith if (!hostTabId) { throw new Error('tab_not_found') } + this.requireHeadlessTargetGroup(snapshot, move.targetGroupId) const split = buildHeadlessTabGroupSplit({ groups: snapshot.tabGroups ?? [], layout: snapshot.tabGroupLayout, @@ -128,6 +122,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith if (!hostTabId) { throw new Error('tab_not_found') } + this.requireHeadlessTargetGroup(snapshot, move.targetGroupId) const moved = buildHeadlessTabGroupMove({ groups: snapshot.tabGroups ?? [], layout: snapshot.tabGroupLayout, @@ -136,7 +131,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith index: move.index }) if (!moved) { - // Same-group / missing-target drop is a renderer no-op; mirror that. + // Same-group drop is a renderer no-op; mirror that. return { moved: true } } const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId } @@ -155,18 +150,64 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith return { moved: true } } - // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split. + // Why: the builders return null for a missing target AND a legit no-op; only the no-op may succeed. + protected requireHeadlessTargetGroup( + snapshot: RuntimeMobileSessionTabsSnapshot, + targetGroupId: string + ): RuntimeMobileSessionTabGroup { + const targetGroup = this.toMobileSessionTabsResult(snapshot).tabGroups?.find( + (group) => group.id === targetGroupId + ) + if (!targetGroup) { + throw new Error('target_group_not_found') + } + return targetGroup + } + + // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split; the tab bar and rows + // follow the groups. Deleted by the switch (PR 6), when moves are layout commands. protected persistHeadlessTabGroups( worktreeId: string, groups: readonly RuntimeMobileSessionTabGroup[], - layout: TabGroupLayoutNode + layout: TabGroupLayoutNode | undefined ): void { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } + const order = groups.flatMap((group) => group.tabOrder) + const groupIdByTabId = new Map( + groups.flatMap((group) => group.tabOrder.map((tabId) => [tabId, group.id] as const)) + ) + const position = (tabId: string): number => { + const index = order.indexOf(tabId) + return index === -1 ? order.length : index + } + const entries = session.unifiedTabs?.[worktreeId] + const rows = session.tabsByWorktree[worktreeId] this.setWorkspaceSessionForWorktree(worktreeId, { ...session, + ...(rows + ? { + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: [...rows] + .sort((a, b) => position(a.id) - position(b.id) || a.sortOrder - b.sortOrder) + .map((row, sortOrder) => ({ ...row, sortOrder })) + } + } + : {}), + ...(entries + ? { + unifiedTabs: { + ...session.unifiedTabs, + [worktreeId]: entries.map((entry) => ({ + ...entry, + groupId: groupIdByTabId.get(entry.id) ?? entry.groupId + })) + } + } + : {}), tabGroups: { ...session.tabGroups, [worktreeId]: groups.map((group) => ({ @@ -177,10 +218,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) })) }, - tabGroupLayouts: { - ...session.tabGroupLayouts, - [worktreeId]: layout - } + ...(layout ? { tabGroupLayouts: { ...session.tabGroupLayouts, [worktreeId]: layout } } : {}) }) } } diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index de9890862abd..c87488dd92d9 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import { runtimeSplitPlacement } from './runtime-terminal-spawn-placement' import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core' import type { Worktree } from '../../shared/worktree/types' +import { spawnInAdmittedPane, withdrawRuntimePane } from './runtime-pane-admission' export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitTerminal { protected async splitPtyBackedTerminal( @@ -56,7 +57,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT const leafId = randomUUID() const preAllocatedHandle = this.createPreAllocatedTerminalHandle() const paneKey = makePaneKey(parentTabId, leafId) - const result = await this.ptyController.spawn({ + const splitArgs = { cols: 120, rows: 40, cwd: workspace.path, @@ -89,7 +90,8 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT } } : {}) - }) + } + const result = await spawnInAdmittedPane(this.store, this.ptyController, splitArgs) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) @@ -150,17 +152,6 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT revalidateSourceAuthority() } if (createdPty) { - const persisted = this.persistHeadlessTerminalSplit({ - worktreeId: workspace.id, - tabId: parentTabId, - leafId, - ptyId: createdPty.ptyId, - splitFromLeafId: parsedPaneKey.leafId, - direction - }) - if (sourceAuthority.persisted && !persisted) { - throw new Error('workspace_session_unavailable') - } this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { tabId: parentTabId, leafId, @@ -192,6 +183,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT } catch { // Best-effort cleanup; preserve the original split authority error. } + await withdrawRuntimePane(this.store, splitArgs) throw error } const committedSourceAuthority = sourceAuthority.persisted diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 8369a219331e..a59074a82dc5 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -1,3 +1,4 @@ +import { withRuntimePaneAdmission } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' @@ -96,7 +97,7 @@ function createHarness( addedAt: 1, ...(connectionId ? { connectionId } : {}) } - const store = { + const store = withRuntimePaneAdmission({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getWorkspaceSession: (hostId?: string) => { @@ -107,7 +108,7 @@ function createHarness( session = next }, persistPtyBinding: () => true - } + }) let resolveSpawn: ((result: { id: string }) => void) | undefined const spawn = options.deferSpawn ? vi.fn( @@ -319,7 +320,6 @@ describe('remote runtime terminal split authority', () => { [SOURCE_LEAF_ID]: SOURCE_PTY_ID } }) - expect(Object.values(persistedLayout!.ptyIdsByLeafId!)).toContain(SPLIT_PTY_ID) const siblingSurfaces = harness .getSnapshot()! .tabs.filter( @@ -432,37 +432,20 @@ describe('remote runtime terminal split authority', () => { expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true) }) - it('revalidates a projected paired-runtime source after renderer adoption', async () => { - const harness = createHarness(false, { - deferReveal: true, - includePairedSnapshot: true, - sourceIncarnationId: 'projected-before', - stopAndWaitResult: false - }) - - const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) - await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) - expect(harness.spawn).toHaveBeenCalledWith( - expect.not.objectContaining({ expectedSourceBinding: expect.anything() }) - ) - - harness.replaceSourceIncarnation('projected-after') - harness.resolveReveal() + it('refuses a split whose source is only projected, before spawning', async () => { + const harness = createHarness(false, { includePairedSnapshot: true }) - await expect(split).rejects.toThrow('terminal_split_source_not_found') - expect(harness.stopAndWait).toHaveBeenCalledWith( - SPLIT_PTY_ID, - expect.objectContaining({ deadlineMs: expect.any(Number) }) - ) - expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) - expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false) + await expect( + harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + ).rejects.toThrow('terminal_split_source_not_found') + expect(harness.spawn).not.toHaveBeenCalled() + expect(harness.revealTerminalSession).not.toHaveBeenCalled() }) - it('preserves the split error when kill and retirement throw', async () => { - const harness = createHarness(false, { - deferReveal: true, - includePairedSnapshot: true, - sourceIncarnationId: 'projected-before', + it('preserves the split error and takes back the pane when kill and retirement throw', async () => { + const harness = createHarness(true, { + deferSpawn: true, + includePairedSnapshot: false, stopAndWaitResult: false }) harness.kill.mockImplementation(() => { @@ -471,14 +454,19 @@ describe('remote runtime terminal split authority', () => { harness.retireRejectedPty.mockImplementation(() => { throw new Error('retire failed') }) + harness.replacePersistedSourceIncarnation('persisted-before') const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) - await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) - harness.replaceSourceIncarnation('projected-after') - harness.resolveReveal() + await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce()) + harness.replacePersistedSourceIncarnation('persisted-after') + harness.resolveSpawn() await expect(split).rejects.toThrow('terminal_split_source_not_found') expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false) + expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SOURCE_LEAF_ID + }) }) }) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 63a29dbc717f..fe61bcbb7da4 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -1,4 +1,4 @@ -import { withDurableRuntimeStore } from './runtime-durable-store-fixture' +import { withDurableRuntimeStore, withRuntimePaneAdmission } from './runtime-durable-store-fixture' import { expect, vi } from 'vitest' import { createHash } from 'node:crypto' import { HeadlessEmulator } from '../daemon/headless-emulator' @@ -580,46 +580,48 @@ function makeRuntimeStoreWithWorkspaceSession( const setSession = (next: WorkspaceSessionState): void => { session = next } - const runtimeStore = withDurableRuntimeStore({ - ...store, - getWorkspaceSession: (hostId?: string) => - hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), - setWorkspaceSession: vi.fn(setSession), - // Headless close is a durable transaction; keep the in-memory fixture's - // persistence contract equivalent to the production store. - flushOrThrow: vi.fn(), - persistPtyBinding: vi.fn(async (input) => { - const args = typeof input === 'function' ? input() : input - if (!args) { - return false - } - const tabs = session.tabsByWorktree[args.worktreeId] ?? [] - session = { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [args.worktreeId]: tabs.map((tab) => - tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab - ) - }, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...(session.terminalLayoutsByTabId[args.tabId] ?? { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null - }), - ptyIdsByLeafId: { - ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, - [args.leafId]: args.ptyId + const runtimeStore = withRuntimePaneAdmission( + withDurableRuntimeStore({ + ...store, + getWorkspaceSession: (hostId?: string) => + hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), + setWorkspaceSession: vi.fn(setSession), + // Headless close is a durable transaction; keep the in-memory fixture's + // persistence contract equivalent to the production store. + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(async (input) => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return false + } + const tabs = session.tabsByWorktree[args.worktreeId] ?? [] + session = { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [args.worktreeId]: tabs.map((tab) => + tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab + ) + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + ...(session.terminalLayoutsByTabId[args.tabId] ?? { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null + }), + ptyIdsByLeafId: { + ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } } } } - } - return true + return true + }) }) - }) + ) return { runtimeStore, getSession: () => session, setSession } } diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts index 99a830ebe41f..3fe67dab7190 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts @@ -316,7 +316,9 @@ describe('OrcaRuntimeService', () => { unsubscribe() expect(setWorktreeMeta).toHaveBeenCalledTimes(2) - expect(events).toEqual([{ type: 'worktreesChanged', repoId: TEST_REPO_ID }]) + expect(events).toEqual([ + { type: 'worktreesChanged', repoId: TEST_REPO_ID, sortOrderOnly: true } + ]) }) it('worktree scan cache: folder metadata invalidation preserves raw scans', async () => { diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts index 612efbb77b82..3b4c48eeddb7 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts @@ -176,4 +176,55 @@ describe('OrcaRuntimeService', () => { expect(listed.tabGroups).toHaveLength(1) expect(storedActiveGroupId(runtime)).toBe(listed.tabGroups?.[0]?.id) }) + + it('keeps phone and CLI creates in the group they joined across a cold restart', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeTabId: 'serve-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'serve-tab' }, + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-left' }, + tabGroups: { [TEST_WORKTREE_ID]: SPLIT_GROUPS }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + makeTerminalTab('serve-tab', SERVE_PTY_ID, 0), + makeTerminalTab('daemon-tab', DAEMON_PTY_ID, 1) + ] + }, + terminalLayoutsByTabId: { + 'serve-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: SERVE_PTY_ID }), + 'daemon-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: DAEMON_PTY_ID }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore) + let ptys = 0 + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `created-pty-${++ptys}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + // The phone targets the right group; an untargeted CLI create joins the first, as on main. + const phone = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + targetGroupId: 'group-right' + }) + const cli = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + const tabOrderOf = (session: WorkspaceSessionState, groupId: string) => + session.tabGroups?.[TEST_WORKTREE_ID]?.find((group) => group.id === groupId)?.tabOrder + expect(tabOrderOf(getSession(), 'group-right')).toContain(phone.tab.parentTabId) + expect(tabOrderOf(getSession(), 'group-left')).toContain(cli.tabId) + + const restarted = new OrcaRuntimeService( + makeRuntimeStoreWithWorkspaceSession(getSession()).runtimeStore + ) + const listed = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabGroups?.map((group) => group.tabOrder)).toEqual([ + ['serve-tab', cli.tabId], + ['daemon-tab', phone.tab.parentTabId] + ]) + }) }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts index 1b28f86bd0de..22a822b47881 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts @@ -13,8 +13,10 @@ import { TEST_WORKTREE_PATH, UUID_RE, deferred, + makeRuntimeStoreWithWorkspaceSession, store } from '../orca-runtime-test-fixtures.spec' +import { getDefaultWorkspaceSession } from '../../../shared/constants' describe('OrcaRuntimeService', () => { it('keeps client selection when a renderer session-tab close cannot commit', async () => { @@ -249,17 +251,10 @@ describe('OrcaRuntimeService', () => { it('creates mobile session terminals in a headless runtime server', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) - const runtime = new OrcaRuntimeService(store) - const persistViewMode = vi.spyOn( - runtime as unknown as { - persistHeadlessSessionTabProps: ( - worktreeId: string, - tabId: string, - props: { viewMode: 'terminal' | 'chat' } - ) => void - }, - 'persistHeadlessSessionTabProps' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + getDefaultWorkspaceSession() ) + const runtime = new OrcaRuntimeService(runtimeStore) runtime.setPtyController({ spawn, write: () => true, @@ -289,9 +284,12 @@ describe('OrcaRuntimeService', () => { viewMode: 'chat', isActive: true }) - expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, { - viewMode: 'chat' - }) + // Written with the pane before spawn, so a serve restart keeps the initial mode. + expect( + getSession().tabsByWorktree[TEST_WORKTREE_ID]?.find( + (tab) => tab.id === result.tab.parentTabId + )?.viewMode + ).toBe('chat') const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(listed.tabs).toEqual([ diff --git a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts index 8b3e0a4d8aba..6b1a4db4ac53 100644 --- a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts @@ -221,6 +221,12 @@ describe('OrcaRuntimeService', () => { registerSshGitProvider('ssh-1', provider as never) getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() }) const runtime = new OrcaRuntimeService(remoteStore as never) + const lineageAtEvent: (WorktreeLineage | undefined)[] = [] + const unsubscribe = runtime.onClientEvent((event) => { + if (event.type === 'worktreesChanged') { + lineageAtEvent.push(lineageById[childId]) + } + }) try { const result = await runtime.createManagedWorktree({ @@ -228,6 +234,9 @@ describe('OrcaRuntimeService', () => { name: 'child-feature', lineage: { parentWorktree: `id:${parentId}` } }) + // Paired clients skip lineage on order saves, so a change event must follow the lineage write. + expect(lineageAtEvent.at(-1)).toBe(lineageById[childId]) + expect(lineageAtEvent.at(-1)).toBeDefined() expect(result.worktree).toMatchObject({ id: childId, @@ -246,6 +255,7 @@ describe('OrcaRuntimeService', () => { expect(addWorktree).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() } finally { + unsubscribe() unregisterSshGitProvider('ssh-1') } }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts index eca4bd125140..0f121cc56d55 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts @@ -1,3 +1,4 @@ +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -10,7 +11,11 @@ import { runHook, shouldRunSetupForCreate } from '../orca-runtime-test-mocks.spec' -import { expectStablePaneKeyEnv, store } from '../orca-runtime-test-fixtures.spec' +import { + expectStablePaneKeyEnv, + store, + makeRuntimeStoreWithWorkspaceSession +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('sequences setup before startup for opted-in local headless worktree creates', async () => { @@ -517,7 +522,7 @@ describe('OrcaRuntimeService', () => { it('honors split setup placement for CLI-created worktrees without startup agents', async () => { const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), setupScriptLaunchMode: 'split-vertical' as const diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index f40f25da7e48..0b804598cb4a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -1,3 +1,4 @@ +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { OrcaRuntimeService, @@ -14,12 +15,17 @@ import { shouldRunSetupForCreate } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' -import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + makeWorktreeMeta, + store, + makeRuntimeStoreWithWorkspaceSession +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('does not surface the new workspace when a background create splits its setup pane', async () => { const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), setupScriptLaunchMode: 'split-horizontal' as const @@ -102,7 +108,7 @@ describe('OrcaRuntimeService', () => { it('still surfaces the new workspace when an activating create splits its setup pane', async () => { const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), setupScriptLaunchMode: 'split-horizontal' as const diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index c883db499c59..63ba48a6611a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -1,3 +1,4 @@ +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -14,7 +15,12 @@ import { shouldRunSetupForCreate } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' -import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + makeWorktreeMeta, + store, + makeRuntimeStoreWithWorkspaceSession +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('sends follow-up prompts for CLI-created stdin-after-start startup agents', async () => { @@ -233,7 +239,7 @@ describe('OrcaRuntimeService', () => { it('honors split setup placement for opted-in local startup-draft worktrees', async () => { const metaById: Record = {} const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), defaultTuiAgent: 'codex' as const, diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts deleted file mode 100644 index 9b3452bd8231..000000000000 --- a/src/main/runtime/qoder-transcripts.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { extractLastOscTitle } from '../../shared/osc-title-extraction' -import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' -import { RuntimeMachineName } from './runtime-machine-name' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -let machineNameStartSpy: { mockRestore(): void } | undefined - -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) -}) - -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined -}) - -describe('captured Qoder 1.1.64 startup', () => { - it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( - 'preserves Qoder identity in %s', - async (fixture) => { - const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') - // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. - const title = extractLastOscTitle( - data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') - ) - expect(title).toContain(' | Ready') - expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: title ?? '', - foregroundProcess: 'qodercli-1.1.64', - launchAgent: 'qoder', - data, - size: { cols: 100, rows: 32 } - }) - const shown = await runtime.showTerminal(handle) - expect(shown.agentIdentity).toBe('qoder') - if (fixture === 'qoder-trust-dialog') { - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 600).catch(() => null) - expect(readiness?.satisfied ?? false).toBe(false) - } - if (fixture === 'qoder-ready') { - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 1500) - expect(readiness.satisfied).toBe(true) - } - } - ) -}) - -describe('captured Qoder China 1.1.65 startup', () => { - it.each(['qoder-cn-startup', 'qoder-cn-signin'])('checks the composer in %s', async (fixture) => { - const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') - const title = extractLastOscTitle( - data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') - ) - expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: title ?? '', - foregroundProcess: 'qoderclicn', - launchAgent: 'qoder-cn', - data, - size: { cols: 120, rows: 40 } - }) - expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn') - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 800).catch(() => null) - expect(readiness?.satisfied ?? false).toBe(false) - }) -}) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 17b2a4c39677..f9d76f4acdd0 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -340,6 +340,98 @@ describe('aiVault.listSessions handler + shared cache', () => { ) }) + it('omits unchanged session bodies only for clients with the matching fingerprint', async () => { + const dispatcher = makeDispatcher() + const first = await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { afterFingerprint: null }) + ) + if ( + !first.ok || + typeof first.result !== 'object' || + first.result === null || + !('fingerprint' in first.result) || + typeof first.result.fingerprint !== 'string' + ) { + throw new Error('Opt-in response did not include a fingerprint') + } + const afterFingerprint = first.result.fingerprint + expect(afterFingerprint).toMatch(/^[0-9a-f]{64}$/) + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { afterFingerprint })) + ).resolves.toMatchObject({ + ok: true, + result: { unchanged: true, fingerprint: afterFingerprint } + }) + const legacy = await dispatcher.dispatch(makeRequest('aiVault.listSessions', {})) + expect(legacy).toMatchObject({ ok: true, result: makeResult() }) + if (legacy.ok) { + expect(legacy.result).not.toHaveProperty('fingerprint') + } + }) + + it('keeps unlimited history un-hashed and full even with an opt-in request', async () => { + const dispatcher = makeDispatcher() + const response = await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { + unlimited: true, + afterFingerprint: null + }) + ) + expect(response).toMatchObject({ ok: true, result: makeResult() }) + if (response.ok) { + expect(response.result).not.toHaveProperty('fingerprint') + expect(response.result).not.toHaveProperty('unchanged') + } + expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({ unlimited: true }) + }) + + it('fingerprints live projected content even when the cached scan stamp does not change', async () => { + const source = { ...makeResult(), sessions: [makeSession()] } + scanAiVaultSessionsInService.mockResolvedValue(source) + const dispatcher = makeDispatcher() + const first = await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { afterFingerprint: null }) + ) + if ( + !first.ok || + typeof first.result !== 'object' || + first.result === null || + !('fingerprint' in first.result) || + typeof first.result.fingerprint !== 'string' + ) { + throw new Error('Expected fingerprint') + } + source.sessions[0].title = 'Changed without a new scan stamp' + const changed = await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { + afterFingerprint: first.result.fingerprint + }) + ) + expect(changed).toMatchObject({ + ok: true, + result: { + sessions: [{ title: 'Changed without a new scan stamp' }], + scannedAt: SCANNED_AT + } + }) + if (changed.ok) { + expect(changed.result).not.toHaveProperty('unchanged') + expect(changed.result).not.toMatchObject({ fingerprint: first.result.fingerprint }) + } + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) + const forced = await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { + afterFingerprint: first.result.fingerprint, + force: true + }) + ) + expect(forced.ok).toBe(true) + if (forced.ok) { + expect(forced.result).not.toHaveProperty('unchanged') + } + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2) + }) + it('returns the AiVaultListResult unchanged', async () => { const dispatcher = makeDispatcher() const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 01e0cbe0e927..3bfa0b904795 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -8,6 +8,7 @@ import { sessionSearchServiceStatus } from '../../../ai-vault-search/session-search-service-registry' import { defineMethod } from '../core' +import { conditionalAiVaultListResult } from '../../../ai-vault/session-list-fingerprint' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' @@ -105,9 +106,13 @@ export const AI_VAULT_METHODS = [ clientKind === undefined || (clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) ) - return params.executionHostId - ? restampAiVaultListResult(projected, params.executionHostId) - : projected + return conditionalAiVaultListResult( + params.executionHostId + ? restampAiVaultListResult(projected, params.executionHostId) + : projected, + params.unlimited ? undefined : params.afterFingerprint, + params.force + ) } }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts index 1ebdf1056dc1..9aa659da6683 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts @@ -15,6 +15,7 @@ function oversizedRow(message: NativeChatMessage): NativeChatMessage { blocks: [{ type: 'text', text, presentation }], timestamp: message.timestamp, source: message.source, + ...(message.transcriptOrder !== undefined ? { transcriptOrder: message.transcriptOrder } : {}), ...(message.transcriptOffset !== undefined ? { transcriptOffset: message.transcriptOffset } : {}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts deleted file mode 100644 index 75f69c06ee5d..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('Run delivery history', () => { - const h = createOrchestrationRpcHarness() - afterEach(() => h.cleanup()) - - it('does not label filtered history as an acknowledgeable delivery', async () => { - const { db, ctx, activeRunId } = h.setup() - const params = { terminal: 'term_coord', run: activeRunId, all: true } - db.insertMessage({ - from: 'worker', - to: `run:${activeRunId}`, - runId: activeRunId, - subject: 'waiting' - }) - expect(await h.call('orchestration.check', params, ctx)).toMatchObject({ - count: 1 - }) - expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(false) - const delivery = db.getOrCreateRunDelivery({ - runId: activeRunId!, - consumerGeneration: db.getRun(activeRunId!)!.consumer_generation - })! - db.insertMessage({ - from: 'worker', - to: `run:${activeRunId}`, - runId: activeRunId, - subject: 'later completion', - type: 'worker_done' - }) - const history = await h.call( - 'orchestration.check', - { - ...params, - format: true, - types: 'worker_done' - }, - ctx - ) - expect(history).toMatchObject({ count: 1, messages: [{ subject: 'later completion' }] }) - expect(history).not.toHaveProperty('deliveryId') - expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(true) - expect(db.getMessageById(delivery.messages[0].id)?.read).toBe(0) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts deleted file mode 100644 index 9287cc0c1dc4..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const PANE_OLD = 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' -const PANE_NEW = 'tab_new:dddddddd-dddd-4ddd-8ddd-dddddddddddd' - -type CheckResult = { messages: { subject: string }[]; count: number } - -/** - * worker-abandon + worker-start --retry-of moves the Task to another terminal, but the old worker - * keeps polling. Its check used to fall through to the direct mailbox and answer `count: 0`, which - * the worker contract reads as "checkpoint, not a failure" — so it kept editing the new owner's files. - */ -describe('orchestration.check from a terminal whose Attempt was superseded', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let ctx: RpcContext - - afterEach(() => { - h.cleanup() - }) - - function check(handle: string, paneKey: string, params: Record = {}) { - return h.call( - 'orchestration.check', - { terminal: handle, terminalPaneKey: paneKey, ...params }, - ctx - ) as Promise - } - - function startWorker(taskId: string, handle: string, paneKey: string, retryOf?: string): string { - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId, - retryOf, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle, - paneKey, - processIncarnation: `runtime:${handle}:1`, - worktreeId: 'repo::local', - setupState: 'not_applicable', - effects: [] - }) - return started.dispatch.id - } - - function retriedOntoAnotherTerminal(): string { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that moves terminals' }) - const abandoned = startWorker(task.id, 'term_old', PANE_OLD) - db.abandonWorkerDispatch(abandoned, 'epoch_test') - startWorker(task.id, 'term_new', PANE_NEW, abandoned) - return abandoned - } - - it('tells the old worker it lost the Dispatch instead of answering "no mail"', async () => { - retriedOntoAnotherTerminal() - - await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ - code: 'consumer_fenced', - message: expect.stringContaining('no longer owns its Dispatch') - }) - }) - - // The direct mailbox is the old terminal's own, so inspection stays open; only the consuming - // read that a worker treats as a checkpoint is refused. - it('still lets the old worker inspect its direct mailbox with --peek and --all', async () => { - retriedOntoAnotherTerminal() - db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'stand down' }) - - const peeked = await check('term_old', PANE_OLD, { peek: true }) - const history = await check('term_old', PANE_OLD, { all: true }) - - expect(peeked.count).toBe(1) - expect(history.count).toBe(1) - expect(db.getUnreadMessages('term_old')).toHaveLength(1) - }) - - it('fences a terminal whose Attempt failed with no successor', async () => { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that failed outright' }) - const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) - db.failDispatch(dispatch.id, 'worker terminal closed') - - await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ code: 'consumer_fenced' }) - }) - - // A superseded worker whose pane is gone cannot run-use either; the stop signal outranks the - // rebind advice, and a caller with no settled Attempt still gets the rebind advice. - it('fences a paneless caller whose Attempt was superseded, and only that caller', async () => { - retriedOntoAnotherTerminal() - - await expect( - h.call('orchestration.check', { terminal: 'term_old' }, ctx) - ).rejects.toMatchObject({ code: 'consumer_fenced' }) - await expect( - h.call('orchestration.check', { terminal: 'term_never_dispatched' }, ctx) - ).rejects.toMatchObject({ code: 'stable_pane_required' }) - }) - - it('keeps serving direct mail to a terminal whose Attempt completed normally', async () => { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that finished' }) - const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) - db.completeDispatch(dispatch.id) - db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'one more thing' }) - - const result = await check('term_old', PANE_OLD) - - expect(result.messages.map((message) => message.subject)).toEqual(['one more thing']) - expect(db.getUnreadMessages('term_old')).toEqual([]) - }) - - it('serves the new owner its Dispatch mailbox as usual', async () => { - const abandoned = retriedOntoAnotherTerminal() - const current = db.getDispatchContext(db.getDispatchContextById(abandoned)!.task_id)! - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${current.id}`, - subject: 'carry on', - runId: current.run_id - }) - - const result = await check('term_new', PANE_NEW) - - expect(result.messages.map((message) => message.subject)).toEqual(['carry on']) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts index 54c64feed624..d161cd9172eb 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts @@ -1,7 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' +import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' import { createRootDispatch, reattachDispatchConsumer @@ -231,3 +233,383 @@ describe('orchestration.check on a re-attached Dispatch', () => { expect(result.messages.map((message) => message.subject)).toEqual(['do the work']) }) }) + +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const OTHER_WORKER_PANE = 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const NON_PARTY_PANE = 'tab_teammate:dddddddd-dddd-4ddd-8ddd-dddddddddddd' +const WORKER_PROCESS = 'runtime_test:term_worker:1' + +describe('worker report authority without a Dispatch capability', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let panes: Record + + afterEach(() => harness.cleanup()) + + function setup(): void { + ;({ db, runtime } = harness.setup()) + ctx = { runtime } + panes = { + term_coord: harness.coordinatorPaneKey, + term_worker: WORKER_PANE, + term_other: OTHER_WORKER_PANE, + term_teammate: NON_PARTY_PANE + } + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( + (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null + ) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + } + + function startWorker(name: string, paneKey: string): { taskId: string; dispatchId: string } { + const task = db.createTask({ spec: `${name} work` }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: `term_${name}`, + paneKey, + processIncarnation: `runtime_test:term_${name}:1`, + worktreeId: `repo::${name}`, + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { taskId: task.id, dispatchId: started.dispatch.id } + } + + function workerDone( + worker: { taskId: string; dispatchId: string }, + evidence?: OrchestrationCompatibilityEvidence, + outcome = 'succeeded' + ) { + return harness.call( + 'orchestration.send', + { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...worker, outcome }) + }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + function ask(evidence?: OrchestrationCompatibilityEvidence) { + return harness.call( + 'orchestration.ask', + { from: 'term_worker', question: 'Proceed?', timeoutMs: 1 }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + describe('caller fence', () => { + it.each([ + ['the Run coordinator', harness.coordinatorPaneKey], + ['another Dispatch worker', OTHER_WORKER_PANE] + ])('refuses a report sent from %s terminal and records nothing', async (_party, paneKey) => { + setup() + startWorker('other', OTHER_WORKER_PANE) + const worker = startWorker('worker', WORKER_PANE) + + await expect(workerDone(worker, { paneKey })).rejects.toMatchObject({ + code: 'consumer_fenced', + data: { effectsApplied: false } + }) + await expect(ask({ paneKey })).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect( + db.db + .prepare("SELECT COUNT(*) AS count FROM messages WHERE from_handle = 'term_worker'") + .get() + ).toEqual({ count: 0 }) + expect(db.getTask(worker.taskId)?.status).toBe('dispatched') + }) + + it.each<[string, OrchestrationCompatibilityEvidence | undefined]>([ + ['no identity env (old CLI, scrubbed env)', undefined], + ['its own pane', { paneKey: WORKER_PANE, terminalHandle: 'term_stale_after_remint' }], + ["another Orca's pane", { paneKey: 'tab_elsewhere:eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }], + ['a stale handle', { terminalHandle: 'term_gone' }], + ['a live pane that is no orchestration party', { paneKey: NON_PARTY_PANE }] + ])('accepts a report whose env names %s', async (_case, evidence) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + + expect(await workerDone(worker, evidence)).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getTask(worker.taskId)?.status).toBe('completed') + }) + + it('passes the fence for a stale --from handle and leaves refusal to the process check', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + panes = { ...panes, term_worker_reminted: WORKER_PANE } + delete panes.term_worker + + expect(await workerDone(worker, { paneKey: WORKER_PANE })).toMatchObject({ + lifecycle: { action: 'rejected', code: 'worker_identity_changed' } + }) + }) + }) + + describe('worker states', () => { + it('refuses reports and questions while a stop is in flight', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { action: 'rejected', code: 'dispatch_inactive' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.getWorkerDispatch(worker.dispatchId)?.state).toBe('stopping') + }) + + it.each([ + ['succeeded', 'completed', 'succeeded'], + ['failed', 'failed', 'failed'] + ])( + 'settles a %s report after a stop whose outcome is unknown', + async (outcome, taskStatus, workerState) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + + expect(await workerDone(worker, undefined, outcome)).toMatchObject({ + lifecycle: { action: taskStatus } + }) + expect(db.getTask(worker.taskId)?.status).toBe(taskStatus) + expect(db.getWorkerDispatch(worker.dispatchId)).toMatchObject({ + state: workerState, + ...(outcome === 'succeeded' ? { last_error: null } : {}) + }) + } + ) + + it('refuses a stale process in the worker pane after an unknown stop', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { code: 'worker_identity_changed' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'worker_identity_changed' }) + }) + }) + + describe('remote worker states', () => { + function startRemoteWorker(): string { + const dispatchId = 'ctx_remote_worker' + db.createRemoteDispatchAttachment({ + runId: 'run-home', + dispatchId, + taskId: 'task_remote_worker', + homePeerFingerprint: 'run-home-device', + protocolVersion: 1, + runtimeEpoch: 'epoch_worker_host', + mutationReceipt: { + callerFingerprint: 'run-home-device', + requestId: 'remote_attach', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_attach_payload' + } + }) + db.prepareRemoteAttachmentAuthority({ + dispatchId, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS, + worktreeId: 'repo::remote', + terminalHandle: 'term_worker', + setupState: 'completed', + effects: [] + }) + return dispatchId + } + + it.each([ + [ + 'stop_unknown', + (dispatchId: string) => { + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + db.markRemoteAttachmentStopUnknown(dispatchId, 'tab not owned by Orca') + } + ], + [ + 'start_unknown', + (dispatchId: string) => + db.failRemoteAttachment(dispatchId, 'agent_readiness', 'connection lost', true) + ] + ])('settles a remote report from %s', async (state, reachState) => { + setup() + const dispatchId = startRemoteWorker() + reachState(dispatchId) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe(state) + + expect(await workerDone({ taskId: 'task_remote_worker', dispatchId })).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe('succeeded') + }) + + it('refuses a remote report while the stop is in flight', async () => { + setup() + const dispatchId = startRemoteWorker() + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + + await expect(workerDone({ taskId: 'task_remote_worker', dispatchId })).rejects.toMatchObject({ + code: 'dispatch_inactive' + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.listPendingFederationRelay(dispatchId, 'to_home')).toEqual([]) + }) + }) +}) + +const PANE = 'tab_lead:22222222-2222-4222-9222-222222222222' +const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' +const SESSION = testOrcaSessionId('4bd46b4a-035b-41dd-a122-a9c29122ff11') + +describe.each([false, true])('Dispatch recipient identity (settled=%s)', (settled) => { + const h = createOrchestrationRpcHarness() + let state: ReturnType + let dispatch: ReturnType + + beforeEach(() => { + state = h.setup() + const task = state.db.createTask({ spec: 'lead' }) + dispatch = createRootDispatch(state.db, task.id, 'term_lead', PANE) + if (settled) { + state.db.completeDispatch(dispatch.id) + } + vi.spyOn(state.runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + handle === 'term_lead' ? PANE : h.coordinatorPaneKey + ) + }) + afterEach(() => h.cleanup()) + + function send() { + return h.call( + 'orchestration.send', + { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'follow up' }, + state.ctx + ) + } + function sessionRun() { + state.db.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(SESSION, dispatch.id) + return state.db.createRun({ + objective: 'session lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION + }) + } + async function expectRun(runId: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.stringContaining(`Send to run:${runId} instead`) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ message: { to_handle: `run:${runId}`, run_id: runId } }) + } + } + async function expectNoRedirect(unrelatedRun: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.not.stringContaining(unrelatedRun) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ + message: { to_handle: `dispatch:${dispatch.id}`, run_id: dispatch.run_id } + }) + } + } + + it('uses a durable session binding without a live pane', async () => { + const run = sessionRun() + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectRun(run.id) + }) + + it('uses the recorded session instead of an unrelated Run now occupying the saved pane', async () => { + const run = sessionRun() + state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + await expectRun(run.id) + }) + + it('ignores an old session column left behind by an older binary rebind', async () => { + const run = sessionRun() + state.db.db + .prepare( + 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' + ) + .run('term_other', OTHER, run.id) + await expectNoRedirect(run.id) + }) + + it('does not follow a closed handle to another occupant of its old pane', async () => { + const run = state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectNoRedirect(run.id) + }) + + it.each(['replacement:pty:2', null])( + 'does not redirect with a replaced or unverifiable process: %s', + async (process) => { + const run = state.db.createRun({ + objective: 'pane run', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + state.db.db + .prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?') + .run('original:pty:1', dispatch.id) + vi.mocked(state.runtime.getTerminalProcessIncarnation).mockReturnValue(process) + await expectNoRedirect(run.id) + expect(state.db.getDispatchContextById(dispatch.id)?.status).toBe( + settled ? 'completed' : dispatch.status + ) + } + ) + + it('accepts a reminted tab half with the same pane leaf and process', async () => { + const run = state.db.createRun({ + objective: 'same pane', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue( + PANE.replace('tab_lead', 'tab_restored') + ) + await expectRun(run.id) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts deleted file mode 100644 index 4ab4b6555a09..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const PANE = 'tab_lead:22222222-2222-4222-9222-222222222222' -const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' -const SESSION = testOrcaSessionId('4bd46b4a-035b-41dd-a122-a9c29122ff11') - -describe.each([false, true])('Dispatch recipient identity (settled=%s)', (settled) => { - const h = createOrchestrationRpcHarness() - let state: ReturnType - let dispatch: ReturnType - - beforeEach(() => { - state = h.setup() - const task = state.db.createTask({ spec: 'lead' }) - dispatch = createRootDispatch(state.db, task.id, 'term_lead', PANE) - if (settled) { - state.db.completeDispatch(dispatch.id) - } - vi.spyOn(state.runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => - handle === 'term_lead' ? PANE : h.coordinatorPaneKey - ) - }) - afterEach(() => h.cleanup()) - - function send() { - return h.call( - 'orchestration.send', - { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'follow up' }, - state.ctx - ) - } - function sessionRun() { - state.db.db - .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') - .run(SESSION, dispatch.id) - return state.db.createRun({ - objective: 'session lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: null, - coordinatorOrcaSessionId: SESSION - }) - } - async function expectRun(runId: string) { - if (settled) { - await expect(send()).rejects.toMatchObject({ - code: 'dispatch_inactive', - message: expect.stringContaining(`Send to run:${runId} instead`) - }) - expect(state.db.getInbox()).toEqual([]) - } else { - expect(await send()).toMatchObject({ message: { to_handle: `run:${runId}`, run_id: runId } }) - } - } - async function expectNoRedirect(unrelatedRun: string) { - if (settled) { - await expect(send()).rejects.toMatchObject({ - code: 'dispatch_inactive', - message: expect.not.stringContaining(unrelatedRun) - }) - expect(state.db.getInbox()).toEqual([]) - } else { - expect(await send()).toMatchObject({ - message: { to_handle: `dispatch:${dispatch.id}`, run_id: dispatch.run_id } - }) - } - } - - it('uses a durable session binding without a live pane', async () => { - const run = sessionRun() - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) - await expectRun(run.id) - }) - - it('uses the recorded session instead of an unrelated Run now occupying the saved pane', async () => { - const run = sessionRun() - state.db.createRun({ - objective: 'new occupant', - coordinatorHandle: 'term_other', - coordinatorPaneKey: PANE - }) - await expectRun(run.id) - }) - - it('ignores an old session column left behind by an older binary rebind', async () => { - const run = sessionRun() - state.db.db - .prepare( - 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' - ) - .run('term_other', OTHER, run.id) - await expectNoRedirect(run.id) - }) - - it('does not follow a closed handle to another occupant of its old pane', async () => { - const run = state.db.createRun({ - objective: 'new occupant', - coordinatorHandle: 'term_other', - coordinatorPaneKey: PANE - }) - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) - await expectNoRedirect(run.id) - }) - - it.each(['replacement:pty:2', null])( - 'does not redirect with a replaced or unverifiable process: %s', - async (process) => { - const run = state.db.createRun({ - objective: 'pane run', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: PANE - }) - state.db.db - .prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?') - .run('original:pty:1', dispatch.id) - vi.mocked(state.runtime.getTerminalProcessIncarnation).mockReturnValue(process) - await expectNoRedirect(run.id) - expect(state.db.getDispatchContextById(dispatch.id)?.status).toBe( - settled ? 'completed' : dispatch.status - ) - } - ) - - it('accepts a reminted tab half with the same pane leaf and process', async () => { - const run = state.db.createRun({ - objective: 'same pane', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: PANE - }) - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue( - PANE.replace('tab_lead', 'tab_restored') - ) - await expectRun(run.id) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts index 3f759cc3d15a..c7c4edf23629 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts @@ -1,10 +1,370 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../../../core' +import type { OrchestrationDb, RunRow } from '../../../../orchestration/db' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { DispatchContextRow } from '../../../../orchestration/types' import { createRootDispatch, reattachDispatchConsumer } from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationRpcHarness } from '../rpc-test-harness' +describe('Run delivery history', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('does not label filtered history as an acknowledgeable delivery', async () => { + const { db, ctx, activeRunId } = h.setup() + const params = { terminal: 'term_coord', run: activeRunId, all: true } + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + runId: activeRunId, + subject: 'waiting' + }) + expect(await h.call('orchestration.check', params, ctx)).toMatchObject({ + count: 1 + }) + expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(false) + const delivery = db.getOrCreateRunDelivery({ + runId: activeRunId!, + consumerGeneration: db.getRun(activeRunId!)!.consumer_generation + })! + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + runId: activeRunId, + subject: 'later completion', + type: 'worker_done' + }) + const history = await h.call( + 'orchestration.check', + { + ...params, + format: true, + types: 'worker_done' + }, + ctx + ) + expect(history).toMatchObject({ count: 1, messages: [{ subject: 'later completion' }] }) + expect(history).not.toHaveProperty('deliveryId') + expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(true) + expect(db.getMessageById(delivery.messages[0].id)?.read).toBe(0) + }) +}) + +const PANE_OLD = 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const PANE_NEW = 'tab_new:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +type CheckResult = { messages: { subject: string }[]; count: number } + +/** + * worker-abandon + worker-start --retry-of moves the Task to another terminal, but the old worker + * keeps polling. Its check used to fall through to the direct mailbox and answer `count: 0`, which + * the worker contract reads as "checkpoint, not a failure" — so it kept editing the new owner's files. + */ +describe('orchestration.check from a terminal whose Attempt was superseded', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let ctx: RpcContext + + afterEach(() => { + h.cleanup() + }) + + function check(handle: string, paneKey: string, params: Record = {}) { + return h.call( + 'orchestration.check', + { terminal: handle, terminalPaneKey: paneKey, ...params }, + ctx + ) as Promise + } + + function startWorker(taskId: string, handle: string, paneKey: string, retryOf?: string): string { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + retryOf, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey, + processIncarnation: `runtime:${handle}:1`, + worktreeId: 'repo::local', + setupState: 'not_applicable', + effects: [] + }) + return started.dispatch.id + } + + function retriedOntoAnotherTerminal(): string { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that moves terminals' }) + const abandoned = startWorker(task.id, 'term_old', PANE_OLD) + db.abandonWorkerDispatch(abandoned, 'epoch_test') + startWorker(task.id, 'term_new', PANE_NEW, abandoned) + return abandoned + } + + it('tells the old worker it lost the Dispatch instead of answering "no mail"', async () => { + retriedOntoAnotherTerminal() + + await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ + code: 'consumer_fenced', + message: expect.stringContaining('no longer owns its Dispatch') + }) + }) + + // The direct mailbox is the old terminal's own, so inspection stays open; only the consuming + // read that a worker treats as a checkpoint is refused. + it('still lets the old worker inspect its direct mailbox with --peek and --all', async () => { + retriedOntoAnotherTerminal() + db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'stand down' }) + + const peeked = await check('term_old', PANE_OLD, { peek: true }) + const history = await check('term_old', PANE_OLD, { all: true }) + + expect(peeked.count).toBe(1) + expect(history.count).toBe(1) + expect(db.getUnreadMessages('term_old')).toHaveLength(1) + }) + + it('fences a terminal whose Attempt failed with no successor', async () => { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that failed outright' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) + db.failDispatch(dispatch.id, 'worker terminal closed') + + await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ code: 'consumer_fenced' }) + }) + + // A superseded worker whose pane is gone cannot run-use either; the stop signal outranks the + // rebind advice, and a caller with no settled Attempt still gets the rebind advice. + it('fences a paneless caller whose Attempt was superseded, and only that caller', async () => { + retriedOntoAnotherTerminal() + + await expect( + h.call('orchestration.check', { terminal: 'term_old' }, ctx) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + await expect( + h.call('orchestration.check', { terminal: 'term_never_dispatched' }, ctx) + ).rejects.toMatchObject({ code: 'stable_pane_required' }) + }) + + it('keeps serving direct mail to a terminal whose Attempt completed normally', async () => { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that finished' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) + db.completeDispatch(dispatch.id) + db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'one more thing' }) + + const result = await check('term_old', PANE_OLD) + + expect(result.messages.map((message) => message.subject)).toEqual(['one more thing']) + expect(db.getUnreadMessages('term_old')).toEqual([]) + }) + + it('serves the new owner its Dispatch mailbox as usual', async () => { + const abandoned = retriedOntoAnotherTerminal() + const current = db.getDispatchContext(db.getDispatchContextById(abandoned)!.task_id)! + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${current.id}`, + subject: 'carry on', + runId: current.run_id + }) + + const result = await check('term_new', PANE_NEW) + + expect(result.messages.map((message) => message.subject)).toEqual(['carry on']) + }) +}) + +// A lead is dispatched by a root coordinator and then coordinates its own Run from the same pane. +// That pane's `check` reads its own Run mailbox, so mail meant for it must land there. +describe('mail for a lead whose pane coordinates its own Run', () => { + const h = createOrchestrationRpcHarness() + const coordPane = 'tab_coord:11111111-1111-4111-8111-111111111111' + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let rootRun: RunRow + let dispatch: DispatchContextRow + + afterEach(() => { + h.cleanup() + }) + + function setup(): void { + ;({ db, runtime, ctx } = h.setup(false)) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordPane : handle === 'term_lead' ? leadPane : null + ) + rootRun = db.createRun({ + objective: 'root', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: coordPane + }) + const task = db.createTask({ spec: 'lead the sub-project', runId: rootRun.id }) + dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + } + + function bindLeadRun(): RunRow { + return db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + } + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + async function leadInbox(params: Record = {}): Promise { + return call('orchestration.check', { terminal: 'term_lead', ...params }) + } + + function deliveryIdOf(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'deliveryId' in result && + typeof result.deliveryId === 'string' + ) { + return result.deliveryId + } + throw new Error('check returned no delivery') + } + + it('routes dispatch: mail to the Run the assignee pane now coordinates', async () => { + setup() + const leadRun = bindLeadRun() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Follow-up for the lead' + }) + + expect(result).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id }, + warnings: [{ code: 'recipient_run_bound_redirect' }] + }) + expect(await leadInbox()).toMatchObject({ messages: [{ subject: 'Follow-up for the lead' }] }) + }) + + it('still reads dispatch mail that arrived before the pane bound its own Run', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Sent before the lead bound a Run', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker report', + runId: leadRun.id + }) + + const first = await leadInbox() + expect(first).toMatchObject({ messages: [{ subject: 'Sent before the lead bound a Run' }] }) + + const second = await leadInbox({ ack: deliveryIdOf(first) }) + expect(second).toMatchObject({ messages: [{ subject: 'Sub-worker report' }] }) + }) + + it('keeps the --types wake condition when older Dispatch mail does not match it', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Older status note', + type: 'status', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker finished', + type: 'worker_done', + runId: leadRun.id + }) + + const woke = await leadInbox({ wait: true, types: 'worker_done', timeoutMs: 500 }) + + expect(woke).toMatchObject({ + runId: leadRun.id, + messages: [{ subject: 'Sub-worker finished' }] + }) + }) + + it('delivers a reply to a Run-bound sender and wakes its waiting check', async () => { + setup() + const leadRun = bindLeadRun() + const report = db.insertMessage({ + from: 'term_lead', + to: `run:${rootRun.id}`, + subject: 'Lead report', + runId: rootRun.id + }) + + const waiting = leadInbox({ wait: true, timeoutMs: 2_000 }) + const reply = await call('orchestration.reply', { + id: report.id, + from: 'term_coord', + body: 'Decision' + }) + + expect(reply).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } + }) + expect(await waiting).toMatchObject({ + timedOut: false, + messages: [{ subject: 'Re: Lead report' }] + }) + }) + + it('keeps dispatch: mail on the Dispatch mailbox while the assignee has no Run', async () => { + setup() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Plain worker follow-up' + }) + + expect(result).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) + expect(result).not.toHaveProperty('warnings') + }) + + it('keeps a reply on the raw handle when the sender has no Run or live pane', async () => { + setup() + const note = db.insertMessage({ + from: 'term_offline', + to: `run:${rootRun.id}`, + subject: 'Offline note', + runId: rootRun.id + }) + + const reply = await call('orchestration.reply', { + id: note.id, + from: 'term_coord', + body: 'Ack' + }) + + expect(reply).toMatchObject({ message: { to_handle: 'term_offline', run_id: rootRun.id } }) + }) +}) + const LEAD = 'tab_lead:22222222-2222-4222-9222-222222222222' const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts deleted file mode 100644 index bb6c7041cee6..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb, RunRow } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { DispatchContextRow } from '../../../../orchestration/types' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -// A lead is dispatched by a root coordinator and then coordinates its own Run from the same pane. -// That pane's `check` reads its own Run mailbox, so mail meant for it must land there. -describe('mail for a lead whose pane coordinates its own Run', () => { - const h = createOrchestrationRpcHarness() - const coordPane = 'tab_coord:11111111-1111-4111-8111-111111111111' - const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let rootRun: RunRow - let dispatch: DispatchContextRow - - afterEach(() => { - h.cleanup() - }) - - function setup(): void { - ;({ db, runtime, ctx } = h.setup(false)) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_coord' ? coordPane : handle === 'term_lead' ? leadPane : null - ) - rootRun = db.createRun({ - objective: 'root', - coordinatorHandle: 'term_coord', - coordinatorPaneKey: coordPane - }) - const task = db.createTask({ spec: 'lead the sub-project', runId: rootRun.id }) - dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) - } - - function bindLeadRun(): RunRow { - return db.createRun({ - objective: 'lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: leadPane - }) - } - - async function call(name: string, params: Record) { - return h.call(name, params, ctx) - } - - async function leadInbox(params: Record = {}): Promise { - return call('orchestration.check', { terminal: 'term_lead', ...params }) - } - - function deliveryIdOf(result: unknown): string { - if ( - typeof result === 'object' && - result !== null && - 'deliveryId' in result && - typeof result.deliveryId === 'string' - ) { - return result.deliveryId - } - throw new Error('check returned no delivery') - } - - it('routes dispatch: mail to the Run the assignee pane now coordinates', async () => { - setup() - const leadRun = bindLeadRun() - - const result = await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Follow-up for the lead' - }) - - expect(result).toMatchObject({ - message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id }, - warnings: [{ code: 'recipient_run_bound_redirect' }] - }) - expect(await leadInbox()).toMatchObject({ messages: [{ subject: 'Follow-up for the lead' }] }) - }) - - it('still reads dispatch mail that arrived before the pane bound its own Run', async () => { - setup() - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Sent before the lead bound a Run', - runId: rootRun.id - }) - const leadRun = bindLeadRun() - db.insertMessage({ - from: 'term_worker', - to: `run:${leadRun.id}`, - subject: 'Sub-worker report', - runId: leadRun.id - }) - - const first = await leadInbox() - expect(first).toMatchObject({ messages: [{ subject: 'Sent before the lead bound a Run' }] }) - - const second = await leadInbox({ ack: deliveryIdOf(first) }) - expect(second).toMatchObject({ messages: [{ subject: 'Sub-worker report' }] }) - }) - - it('keeps the --types wake condition when older Dispatch mail does not match it', async () => { - setup() - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Older status note', - type: 'status', - runId: rootRun.id - }) - const leadRun = bindLeadRun() - db.insertMessage({ - from: 'term_worker', - to: `run:${leadRun.id}`, - subject: 'Sub-worker finished', - type: 'worker_done', - runId: leadRun.id - }) - - const woke = await leadInbox({ wait: true, types: 'worker_done', timeoutMs: 500 }) - - expect(woke).toMatchObject({ - runId: leadRun.id, - messages: [{ subject: 'Sub-worker finished' }] - }) - }) - - it('delivers a reply to a Run-bound sender and wakes its waiting check', async () => { - setup() - const leadRun = bindLeadRun() - const report = db.insertMessage({ - from: 'term_lead', - to: `run:${rootRun.id}`, - subject: 'Lead report', - runId: rootRun.id - }) - - const waiting = leadInbox({ wait: true, timeoutMs: 2_000 }) - const reply = await call('orchestration.reply', { - id: report.id, - from: 'term_coord', - body: 'Decision' - }) - - expect(reply).toMatchObject({ - message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } - }) - expect(await waiting).toMatchObject({ - timedOut: false, - messages: [{ subject: 'Re: Lead report' }] - }) - }) - - it('keeps dispatch: mail on the Dispatch mailbox while the assignee has no Run', async () => { - setup() - - const result = await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Plain worker follow-up' - }) - - expect(result).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) - expect(result).not.toHaveProperty('warnings') - }) - - it('keeps a reply on the raw handle when the sender has no Run or live pane', async () => { - setup() - const note = db.insertMessage({ - from: 'term_offline', - to: `run:${rootRun.id}`, - subject: 'Offline note', - runId: rootRun.id - }) - - const reply = await call('orchestration.reply', { - id: note.id, - from: 'term_coord', - body: 'Ack' - }) - - expect(reply).toMatchObject({ message: { to_handle: 'term_offline', run_id: rootRun.id } }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts index 9f57957a10a2..c3f6d2aec2fc 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrchestrationDb } from '../../../../orchestration/db' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' import { openDecisionGateFromMessage } from '../../../../orchestration/coordinator-decision-gates' import { applyEscalationToDispatch } from '../../../../orchestration/coordinator-escalation-triage' import { createOrchestrationRpcHarness } from '../rpc-test-harness' @@ -143,3 +144,251 @@ describe('orchestration.send Dispatch authority', () => { } ) }) + +// The same delivery plumbing `check` already strips; a send/reply receipt is the same mailbox row. +const INTERNAL_COLUMNS = [ + 'read', + 'sequence', + 'sender_pane_key', + 'pointer_enter_pending', + 'pointer_pty_id', + 'pointer_process_incarnation' +] + +function terminalSummary(handle: string): RuntimeTerminalSummary { + return { + handle, + ptyId: `pty_${handle}`, + worktreeId: 'wt_default', + worktreePath: '/tmp/wt', + branch: 'main', + tabId: 'tab_1', + leafId: handle, + title: null, + connected: true, + writable: true, + lastOutputAt: null, + preview: '' + } +} + +describe('orchestration send and reply receipts', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string | undefined + + afterEach(() => h.cleanup()) + + function setup(): void { + ;({ db, runtime, ctx, activeRunId } = h.setup()) + } + + it('keeps delivery plumbing out of a point-to-point send receipt', async () => { + setup() + + const result = (await h.call( + 'orchestration.send', + { from: 'term_coord', to: `run:${activeRunId}`, subject: 'plumbing' }, + ctx + )) as { message: Record } + + expect(result.message).toMatchObject({ subject: 'plumbing' }) + for (const column of INTERNAL_COLUMNS) { + expect(result.message).not.toHaveProperty(column) + } + }) + + it('keeps delivery plumbing out of a group send receipt', async () => { + setup() + const terminals = [terminalSummary('term_coord'), terminalSummary('term_worker')] + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals, + totalCount: terminals.length, + truncated: false + }) + createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') + + const result = (await h.call( + 'orchestration.send', + { from: 'term_coord', to: '@all', subject: 'group plumbing' }, + ctx + )) as { messages: Record[] } + + expect(result.messages).toHaveLength(1) + for (const message of result.messages) { + for (const column of INTERNAL_COLUMNS) { + expect(message).not.toHaveProperty(column) + } + } + }) + + it('keeps delivery plumbing out of a reply receipt', async () => { + setup() + const original = db.insertMessage({ + from: 'term_worker', + to: `run:${activeRunId}`, + subject: 'Need an answer' + }) + + const result = (await h.call( + 'orchestration.reply', + { id: original.id, body: 'One durable answer', from: 'term_coord' }, + ctx + )) as { message: Record } + + expect(result.message).toMatchObject({ subject: 'Re: Need an answer' }) + for (const column of INTERNAL_COLUMNS) { + expect(result.message).not.toHaveProperty(column) + } + }) +}) + +describe('orchestration.send between terminals in no Run', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { + // Two plain panes and `send --to `: the first command the guide teaches. #19542 + // refused this with a bare "Run is required"; it files under the unbound Run instead. + const { db, runtime, ctx } = h.setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await h.call( + 'orchestration.send', + { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, + ctx + )) as { message: { id: string; run_id: string; to_handle: string } } + + expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) + expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) + expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) + const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { + messages: { id: string }[] + } + expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) + }) +}) + +describe('orchestration.send to a settled Dispatch mailbox', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let ctx: RpcContext + + afterEach(() => { + h.cleanup() + }) + + function setup(): void { + ;({ db, ctx } = h.setup()) + } + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + it.each([ + ['completed', (settledDb: OrchestrationDb, id: string) => settledDb.completeDispatch(id)], + [ + 'failed', + (settledDb: OrchestrationDb, id: string) => + settledDb.failDispatch(id, 'worker terminal closed') + ] + ])('rejects mail to a %s Dispatch instead of reporting success', async (_status, settle) => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + settle(db, dispatch.id) + + await expect( + call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + ).rejects.toMatchObject({ code: 'dispatch_inactive' }) + }) + + it('names the Run mailbox that is still reachable', async () => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + db.completeDispatch(dispatch.id) + + await expect( + call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + ).rejects.toThrow(new RegExp(`run:${dispatch.run_id}`)) + }) + + it('names the Run a settled assignee now coordinates, not the sender Run', async () => { + setup() + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + vi.spyOn(ctx.runtime, 'getLiveTerminalPaneKey').mockReturnValue(leadPane) + const task = db.createTask({ spec: 'lead that settled and kept coordinating' }) + const dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + db.completeDispatch(dispatch.id) + const leadRun = db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + + const rejection = call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + + await expect(rejection).rejects.toMatchObject({ code: 'dispatch_inactive' }) + await expect(rejection).rejects.toThrow(new RegExp(`run:${leadRun.id}`)) + await expect(rejection).rejects.not.toThrow(new RegExp(`run:${dispatch.run_id}`)) + expect(db.getInbox()).toEqual([]) + await expect( + call('orchestration.check', { terminal: 'term_coord', run: leadRun.id }) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + // Run addresses are already visible to callers; a hint grants no consuming authority. + expect(await call('orchestration.runShow', { id: leadRun.id })).toMatchObject({ + run: { id: leadRun.id } + }) + }) + + it('does not write an undeliverable message row', async () => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + db.completeDispatch(dispatch.id) + + await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }).catch(() => undefined) + + const stranded = db.db + .prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?') + .get(`dispatch:${dispatch.id}`) as { count: number } + expect(stranded.count).toBe(0) + }) + + it('still delivers to an active Dispatch mailbox', async () => { + setup() + const task = db.createTask({ spec: 'worker still running' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Pause after this step' + })) as { message: { to_handle: string } } + + expect(result.message.to_handle).toBe(`dispatch:${dispatch.id}`) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts deleted file mode 100644 index 232a5992a872..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' - -// The same delivery plumbing `check` already strips; a send/reply receipt is the same mailbox row. -const INTERNAL_COLUMNS = [ - 'read', - 'sequence', - 'sender_pane_key', - 'pointer_enter_pending', - 'pointer_pty_id', - 'pointer_process_incarnation' -] - -function terminalSummary(handle: string): RuntimeTerminalSummary { - return { - handle, - ptyId: `pty_${handle}`, - worktreeId: 'wt_default', - worktreePath: '/tmp/wt', - branch: 'main', - tabId: 'tab_1', - leafId: handle, - title: null, - connected: true, - writable: true, - lastOutputAt: null, - preview: '' - } -} - -describe('orchestration send and reply receipts', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let activeRunId: string | undefined - - afterEach(() => h.cleanup()) - - function setup(): void { - ;({ db, runtime, ctx, activeRunId } = h.setup()) - } - - it('keeps delivery plumbing out of a point-to-point send receipt', async () => { - setup() - - const result = (await h.call( - 'orchestration.send', - { from: 'term_coord', to: `run:${activeRunId}`, subject: 'plumbing' }, - ctx - )) as { message: Record } - - expect(result.message).toMatchObject({ subject: 'plumbing' }) - for (const column of INTERNAL_COLUMNS) { - expect(result.message).not.toHaveProperty(column) - } - }) - - it('keeps delivery plumbing out of a group send receipt', async () => { - setup() - const terminals = [terminalSummary('term_coord'), terminalSummary('term_worker')] - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals, - totalCount: terminals.length, - truncated: false - }) - createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') - - const result = (await h.call( - 'orchestration.send', - { from: 'term_coord', to: '@all', subject: 'group plumbing' }, - ctx - )) as { messages: Record[] } - - expect(result.messages).toHaveLength(1) - for (const message of result.messages) { - for (const column of INTERNAL_COLUMNS) { - expect(message).not.toHaveProperty(column) - } - } - }) - - it('keeps delivery plumbing out of a reply receipt', async () => { - setup() - const original = db.insertMessage({ - from: 'term_worker', - to: `run:${activeRunId}`, - subject: 'Need an answer' - }) - - const result = (await h.call( - 'orchestration.reply', - { id: original.id, body: 'One durable answer', from: 'term_coord' }, - ctx - )) as { message: Record } - - expect(result.message).toMatchObject({ subject: 'Re: Need an answer' }) - for (const column of INTERNAL_COLUMNS) { - expect(result.message).not.toHaveProperty(column) - } - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts deleted file mode 100644 index 84cb250a19e6..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('orchestration.send between terminals in no Run', () => { - const h = createOrchestrationRpcHarness() - afterEach(() => h.cleanup()) - - it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { - // Two plain panes and `send --to `: the first command the guide teaches. #19542 - // refused this with a bare "Run is required"; it files under the unbound Run instead. - const { db, runtime, ctx } = h.setup(false) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null - ) - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) - - const result = (await h.call( - 'orchestration.send', - { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, - ctx - )) as { message: { id: string; run_id: string; to_handle: string } } - - expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) - expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) - expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) - const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { - messages: { id: string }[] - } - expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts deleted file mode 100644 index ddffed468900..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('orchestration.send to a settled Dispatch mailbox', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let ctx: RpcContext - - afterEach(() => { - h.cleanup() - }) - - function setup(): void { - ;({ db, ctx } = h.setup()) - } - - async function call(name: string, params: Record) { - return h.call(name, params, ctx) - } - - it.each([ - ['completed', (settledDb: OrchestrationDb, id: string) => settledDb.completeDispatch(id)], - [ - 'failed', - (settledDb: OrchestrationDb, id: string) => - settledDb.failDispatch(id, 'worker terminal closed') - ] - ])('rejects mail to a %s Dispatch instead of reporting success', async (_status, settle) => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - settle(db, dispatch.id) - - await expect( - call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - ).rejects.toMatchObject({ code: 'dispatch_inactive' }) - }) - - it('names the Run mailbox that is still reachable', async () => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - db.completeDispatch(dispatch.id) - - await expect( - call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - ).rejects.toThrow(new RegExp(`run:${dispatch.run_id}`)) - }) - - it('names the Run a settled assignee now coordinates, not the sender Run', async () => { - setup() - const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' - vi.spyOn(ctx.runtime, 'getLiveTerminalPaneKey').mockReturnValue(leadPane) - const task = db.createTask({ spec: 'lead that settled and kept coordinating' }) - const dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) - db.completeDispatch(dispatch.id) - const leadRun = db.createRun({ - objective: 'lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: leadPane - }) - - const rejection = call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - - await expect(rejection).rejects.toMatchObject({ code: 'dispatch_inactive' }) - await expect(rejection).rejects.toThrow(new RegExp(`run:${leadRun.id}`)) - await expect(rejection).rejects.not.toThrow(new RegExp(`run:${dispatch.run_id}`)) - expect(db.getInbox()).toEqual([]) - await expect( - call('orchestration.check', { terminal: 'term_coord', run: leadRun.id }) - ).rejects.toMatchObject({ code: 'consumer_fenced' }) - // Run addresses are already visible to callers; a hint grants no consuming authority. - expect(await call('orchestration.runShow', { id: leadRun.id })).toMatchObject({ - run: { id: leadRun.id } - }) - }) - - it('does not write an undeliverable message row', async () => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - db.completeDispatch(dispatch.id) - - await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }).catch(() => undefined) - - const stranded = db.db - .prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?') - .get(`dispatch:${dispatch.id}`) as { count: number } - expect(stranded.count).toBe(0) - }) - - it('still delivers to an active Dispatch mailbox', async () => { - setup() - const task = db.createTask({ spec: 'worker still running' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - - const result = (await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Pause after this step' - })) as { message: { to_handle: string } } - - expect(result.message.to_handle).toBe(`dispatch:${dispatch.id}`) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts deleted file mode 100644 index 6c311baf84d1..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const OTHER_WORKER_PANE = 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' -const NON_PARTY_PANE = 'tab_teammate:dddddddd-dddd-4ddd-8ddd-dddddddddddd' -const WORKER_PROCESS = 'runtime_test:term_worker:1' - -describe('worker report authority without a Dispatch capability', () => { - const harness = createOrchestrationRpcHarness() - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let panes: Record - - afterEach(() => harness.cleanup()) - - function setup(): void { - ;({ db, runtime } = harness.setup()) - ctx = { runtime } - panes = { - term_coord: harness.coordinatorPaneKey, - term_worker: WORKER_PANE, - term_other: OTHER_WORKER_PANE, - term_teammate: NON_PARTY_PANE - } - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) - vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( - (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null - ) - vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') - } - - function startWorker(name: string, paneKey: string): { taskId: string; dispatchId: string } { - const task = db.createTask({ spec: `${name} work` }) - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: `term_${name}`, - paneKey, - processIncarnation: `runtime_test:term_${name}:1`, - worktreeId: `repo::${name}`, - effects: [], - setupState: 'not_applicable', - terminalOwnership: 'created' - }) - db.markWorkerDispatchReady(started.dispatch.id) - return { taskId: task.id, dispatchId: started.dispatch.id } - } - - function workerDone( - worker: { taskId: string; dispatchId: string }, - evidence?: OrchestrationCompatibilityEvidence, - outcome = 'succeeded' - ) { - return harness.call( - 'orchestration.send', - { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ ...worker, outcome }) - }, - { ...ctx, orchestrationCompatibilityEvidence: evidence } - ) - } - - function ask(evidence?: OrchestrationCompatibilityEvidence) { - return harness.call( - 'orchestration.ask', - { from: 'term_worker', question: 'Proceed?', timeoutMs: 1 }, - { ...ctx, orchestrationCompatibilityEvidence: evidence } - ) - } - - describe('caller fence', () => { - it.each([ - ['the Run coordinator', harness.coordinatorPaneKey], - ['another Dispatch worker', OTHER_WORKER_PANE] - ])('refuses a report sent from %s terminal and records nothing', async (_party, paneKey) => { - setup() - startWorker('other', OTHER_WORKER_PANE) - const worker = startWorker('worker', WORKER_PANE) - - await expect(workerDone(worker, { paneKey })).rejects.toMatchObject({ - code: 'consumer_fenced', - data: { effectsApplied: false } - }) - await expect(ask({ paneKey })).rejects.toMatchObject({ code: 'consumer_fenced' }) - expect( - db.db - .prepare("SELECT COUNT(*) AS count FROM messages WHERE from_handle = 'term_worker'") - .get() - ).toEqual({ count: 0 }) - expect(db.getTask(worker.taskId)?.status).toBe('dispatched') - }) - - it.each<[string, OrchestrationCompatibilityEvidence | undefined]>([ - ['no identity env (old CLI, scrubbed env)', undefined], - ['its own pane', { paneKey: WORKER_PANE, terminalHandle: 'term_stale_after_remint' }], - ["another Orca's pane", { paneKey: 'tab_elsewhere:eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }], - ['a stale handle', { terminalHandle: 'term_gone' }], - ['a live pane that is no orchestration party', { paneKey: NON_PARTY_PANE }] - ])('accepts a report whose env names %s', async (_case, evidence) => { - setup() - const worker = startWorker('worker', WORKER_PANE) - - expect(await workerDone(worker, evidence)).toMatchObject({ - lifecycle: { action: 'completed' } - }) - expect(db.getTask(worker.taskId)?.status).toBe('completed') - }) - - it('passes the fence for a stale --from handle and leaves refusal to the process check', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - panes = { ...panes, term_worker_reminted: WORKER_PANE } - delete panes.term_worker - - expect(await workerDone(worker, { paneKey: WORKER_PANE })).toMatchObject({ - lifecycle: { action: 'rejected', code: 'worker_identity_changed' } - }) - }) - }) - - describe('worker states', () => { - it('refuses reports and questions while a stop is in flight', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - - expect(await workerDone(worker)).toMatchObject({ - lifecycle: { action: 'rejected', code: 'dispatch_inactive' } - }) - await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) - expect(db.getWorkerDispatch(worker.dispatchId)?.state).toBe('stopping') - }) - - it.each([ - ['succeeded', 'completed', 'succeeded'], - ['failed', 'failed', 'failed'] - ])( - 'settles a %s report after a stop whose outcome is unknown', - async (outcome, taskStatus, workerState) => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') - - expect(await workerDone(worker, undefined, outcome)).toMatchObject({ - lifecycle: { action: taskStatus } - }) - expect(db.getTask(worker.taskId)?.status).toBe(taskStatus) - expect(db.getWorkerDispatch(worker.dispatchId)).toMatchObject({ - state: workerState, - ...(outcome === 'succeeded' ? { last_error: null } : {}) - }) - } - ) - - it('refuses a stale process in the worker pane after an unknown stop', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') - vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') - - expect(await workerDone(worker)).toMatchObject({ - lifecycle: { code: 'worker_identity_changed' } - }) - await expect(ask()).rejects.toMatchObject({ code: 'worker_identity_changed' }) - }) - }) - - describe('remote worker states', () => { - function startRemoteWorker(): string { - const dispatchId = 'ctx_remote_worker' - db.createRemoteDispatchAttachment({ - runId: 'run-home', - dispatchId, - taskId: 'task_remote_worker', - homePeerFingerprint: 'run-home-device', - protocolVersion: 1, - runtimeEpoch: 'epoch_worker_host', - mutationReceipt: { - callerFingerprint: 'run-home-device', - requestId: 'remote_attach', - method: 'orchestration.federationAttachStart', - payloadHash: 'remote_attach_payload' - } - }) - db.prepareRemoteAttachmentAuthority({ - dispatchId, - paneKey: WORKER_PANE, - processIncarnation: WORKER_PROCESS, - worktreeId: 'repo::remote', - terminalHandle: 'term_worker', - setupState: 'completed', - effects: [] - }) - return dispatchId - } - - it.each([ - [ - 'stop_unknown', - (dispatchId: string) => { - db.markRemoteAttachmentReady(dispatchId) - db.beginRemoteAttachmentStop(dispatchId) - db.markRemoteAttachmentStopUnknown(dispatchId, 'tab not owned by Orca') - } - ], - [ - 'start_unknown', - (dispatchId: string) => - db.failRemoteAttachment(dispatchId, 'agent_readiness', 'connection lost', true) - ] - ])('settles a remote report from %s', async (state, reachState) => { - setup() - const dispatchId = startRemoteWorker() - reachState(dispatchId) - expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe(state) - - expect(await workerDone({ taskId: 'task_remote_worker', dispatchId })).toMatchObject({ - lifecycle: { action: 'completed' } - }) - expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe('succeeded') - }) - - it('refuses a remote report while the stop is in flight', async () => { - setup() - const dispatchId = startRemoteWorker() - db.markRemoteAttachmentReady(dispatchId) - db.beginRemoteAttachmentStop(dispatchId) - - await expect(workerDone({ taskId: 'task_remote_worker', dispatchId })).rejects.toMatchObject({ - code: 'dispatch_inactive' - }) - await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) - expect(db.listPendingFederationRelay(dispatchId, 'to_home')).toEqual([]) - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts deleted file mode 100644 index f93df6e4d7a7..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' -import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -const READY_WAIT = { - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null -} satisfies RuntimeTerminalWait - -describe('Antigravity orchestration worker lifecycle', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { - h.setup() - const readiness = h.deferred() - vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) - - const pending = h.startWorker({ agent: 'antigravity' }) - await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) - - expect(h.runtime.createTerminal).toHaveBeenCalledWith( - 'id:repo::worktree', - expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) - ) - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ - ownership_state: 'owned', - terminal_handle: 'term_worker' - }) - - readiness.resolve(READY_WAIT) - await expect(pending).resolves.toEqual( - expect.objectContaining({ dispatchId: expect.any(String) }) - ) - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) - }) - - it('stops only the owned AGY terminal', async () => { - h.setup() - const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) - - await expect( - h.call('orchestration.workerStop', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) - - it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'releasing' - }) - - await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ - attempted: 1, - released: 1 - }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - release_state: 'released' - }) - expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) - expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') - }) - - it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_unknown' }) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'unknown' - }) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'released' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts deleted file mode 100644 index bbca9ec29ec7..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition -// used to reject it and its abandoned Task had no documented route back. -describe('worker-start --retry-of a context-only Dispatch', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function dispatchContextOnly( - spec: string - ): Promise<{ taskId: string; dispatchId: string }> { - const task = harness.db.createTask({ spec, runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() - return { taskId: task.id, dispatchId: result.dispatch.id } - } - - it('restarts the Task after the attempt is abandoned', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') - - await expect( - harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) - expect(harness.db.getTask(taskId)?.status).toBe('blocked') - - const retried = (await harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - })) as { dispatchId: string; state: string } - - expect(retried.state).toBe('ready') - expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( - dispatchId - ) - expect(harness.db.getTask(taskId)?.status).toBe('dispatched') - }) - - it('still refuses to retry an attempt that has not settled', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('live attempt') - - await expect( - harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - }) - ).rejects.toMatchObject({ code: 'task_not_startable' }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts deleted file mode 100644 index 75ad57f3a120..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -type ListedWorker = { - dispatchId: string - workerState: string - dispatchStatus: string - projection: { - outcome: string - liveness: { verdict: string; reason?: string } - nextAction: { kind: string; argv: string[] } - attention: { categories: string[]; requiresAction: boolean } - } -} - -describe('pre-v3 dispatch rows in worker-list', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no - * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ - function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { - const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) - if (status === 'completed') { - h.db.completeDispatch(dispatch.id) - } - if (status === 'failed') { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - return dispatch.id - } - - async function listWorkers(): Promise> { - const listed = (await h.call('orchestration.workerList', { - paginate: true, - run: h.activeRunId - })) as { workers: ListedWorker[] } - return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) - } - - it('projects a settled legacy dispatch as settled with nothing to act on', async () => { - h.setup() - const completed = createLegacyDispatch('completed') - - const worker = (await listWorkers()).get(completed)! - - expect(worker.workerState).toBe('unsupervised') - expect(worker.dispatchStatus).toBe('completed') - // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` - // report or a task completion, so the durable record is the whole settlement. - expect(worker.projection.outcome).toBe('succeeded') - // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch - // that never had a worker row has no process whose absence could require action. - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'unsupervised_settled' - }) - expect(worker.projection.attention.categories).not.toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(false) - expect(worker.projection.nextAction.kind).toBe('none') - }) - - it.each(['completed', 'failed'] as const)( - 'closes a pending question when a legacy dispatch settles as %s', - async (status) => { - h.setup() - const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) - const asked = h.db.createQuestion({ - runId: h.activeRunId, - dispatchId: dispatch.id, - askerHandle: `term_legacy_q_${status}`, - question: 'Which branch?' - }) - // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. - if (status === 'completed') { - h.db.updateTaskStatus(task.id, 'completed', 'done') - } else { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - - const worker = (await listWorkers()).get(dispatch.id)! - - expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') - expect(worker.dispatchStatus).toBe(status) - expect(worker.projection.attention.categories).not.toContain('input') - // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. - expect(worker.projection.attention.requiresAction).toBe(status === 'failed') - } - ) - - it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { - h.setup() - const failed = createLegacyDispatch('failed') - - const worker = (await listWorkers()).get(failed)! - - expect(worker.dispatchStatus).toBe('failed') - expect(worker.projection.outcome).toBe('failed') - expect(worker.projection.attention.categories).toEqual(['failure']) - expect(worker.projection.attention.requiresAction).toBe(true) - }) - - it('leaves an unsettled legacy dispatch genuinely unknown', async () => { - h.setup() - const dispatched = createLegacyDispatch('dispatched') - - const worker = (await listWorkers()).get(dispatched)! - - expect(worker.projection.outcome).toBe('in_progress') - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'missing_status' - }) - expect(worker.projection.attention.categories).toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(true) - expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts index e340e51b01dd..dca1eddadf45 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type Database from '../../../../../sqlite/sync-database' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import { ORCHESTRATION_METHODS } from '../../orchestration' -import { eraseRpcMethods } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' +import { parseOrcaSessionAddress } from '../../../../../../shared/orca-session-address' const COORDINATOR = 'term_coordinator' const TARGET = 'term_target' @@ -195,3 +197,88 @@ function paneKey(handle: string): string { function sqliteFor(db: OrchestrationDb): Database.Database { return (db as unknown as { db: Database.Database }).db } + +describe('orchestration.workerAbandon', () => { + let db: OrchestrationDb + let runtime: OrcaRuntimeService + + beforeEach(() => { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + }) + afterEach(() => db.close()) + + function readyWorker(): string { + const task = db.createTask({ runId: 'run_legacy_local', spec: 'abandon caller' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + processIncarnation: 'inc_worker', + worktreeId: 'wt', + effects: [], + setupState: 'not_configured' + }) + db.markWorkerDispatchReady(dispatch.id) + return dispatch.id + } + + async function abandon(dispatchId: string, ctx: Partial) { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (m) => m.name === 'orchestration.workerAbandon' + )! + return method.handler(method.params!.parse({ dispatch: dispatchId }), { runtime, ...ctx }) + } + + it('never records an unverified terminal handle as the one who abandoned the worker', async () => { + const dispatchId = readyWorker() + + await expect( + abandon(dispatchId, { orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } }) + ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) + expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( + 'Abandoned by an unidentified caller.' + ) + }) + + it('records the resolved Orca session as the one who abandoned the worker', async () => { + const dispatchId = readyWorker() + const orcaSessionId = parseOrcaSessionAddress('orca_session_id:chat_1')! + const session: OrchestrationSessionCaller = { + address: 'orca_session_id:chat_1', + terminalHandle: null, + paneKey: null, + orcaSessionId, + sessionId: orcaSessionId, + workspaceId: 'wt' + } + + await abandon(dispatchId, { + orchestrationCaller: session, + orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } + }) + expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( + 'Abandoned by orca_session_id:chat_1.' + ) + }) + + it('reports an already-settled worker as stale and changes nothing', async () => { + const dispatchId = readyWorker() + db.failDispatch(dispatchId, 'tab closed', { workerProcessExited: true }) + const before = db.getWorkerDispatch(dispatchId) + + await expect(abandon(dispatchId, {})).resolves.toMatchObject({ + state: 'failed', + alreadySettled: true, + stale: true + }) + expect(db.getWorkerDispatch(dispatchId)).toEqual(before) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts deleted file mode 100644 index bd5becb2a06b..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -// A coordinator that records context against its own terminal delegated nothing, so the row it -// leaves behind must not read back as the coordinator's own parent Attempt. -describe('context-only self-dispatch and nesting depth', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function selfDispatch(): Promise { - const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_coord' - })) as { dispatch: { id: string } } - return result.dispatch.id - } - - it('leaves the coordinator able to start a worker', async () => { - const selfDispatchId = await selfDispatch() - expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ - creator_handle: 'term_coord', - creator_pane_key: harness.coordinatorPaneKey - }) - - const started = await harness.startWorker({ terminal: 'term_worker' }) - - expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ - depth: 1, - creator_dispatch_id: null - }) - }) - - it('still counts a real assignment to another pane as a nesting parent', async () => { - const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) - const delegated = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - - expect( - harness.db.resolveCreatorDepth({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(1) - expect( - harness.db.resolveCreatorDispatchId({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(delegated.dispatch.id) - }) - - it('reports the self-dispatching coordinator as a root', async () => { - await selfDispatch() - - const creator = { - kind: 'terminal', - handle: 'term_coord', - paneKey: harness.coordinatorPaneKey - } as const - expect(harness.db.resolveCreatorDepth(creator)).toBe(0) - expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts deleted file mode 100644 index 36ff0a8cd092..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { OrcaRuntimeService } from '../../../../orca-runtime' -import { OrchestrationDb } from '../../../../orchestration/db' -import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' -import { ORCHESTRATION_METHODS } from '../../orchestration' -import { eraseRpcMethods, type RpcContext } from '../../../core' -import { parseOrcaSessionAddress } from '../../../../../../shared/orca-session-address' - -describe('orchestration.workerAbandon', () => { - let db: OrchestrationDb - let runtime: OrcaRuntimeService - - beforeEach(() => { - db = new OrchestrationDb(':memory:') - runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(db) - }) - afterEach(() => db.close()) - - function readyWorker(): string { - const task = db.createTask({ runId: 'run_legacy_local', spec: 'abandon caller' }) - const { dispatch } = db.createStartingWorkerDispatch({ - taskId: task.id, - startOptions: {}, - creator: { kind: 'system' }, - maxDepth: 9 - }) - db.prepareStartingWorkerAuthority({ - dispatchId: dispatch.id, - handle: 'term_worker', - paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - processIncarnation: 'inc_worker', - worktreeId: 'wt', - effects: [], - setupState: 'not_configured' - }) - db.markWorkerDispatchReady(dispatch.id) - return dispatch.id - } - - async function abandon(dispatchId: string, ctx: Partial) { - const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( - (m) => m.name === 'orchestration.workerAbandon' - )! - return method.handler(method.params!.parse({ dispatch: dispatchId }), { runtime, ...ctx }) - } - - it('never records an unverified terminal handle as the one who abandoned the worker', async () => { - const dispatchId = readyWorker() - - await expect( - abandon(dispatchId, { orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } }) - ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) - expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( - 'Abandoned by an unidentified caller.' - ) - }) - - it('records the resolved Orca session as the one who abandoned the worker', async () => { - const dispatchId = readyWorker() - const orcaSessionId = parseOrcaSessionAddress('orca_session_id:chat_1')! - const session: OrchestrationSessionCaller = { - address: 'orca_session_id:chat_1', - terminalHandle: null, - paneKey: null, - orcaSessionId, - sessionId: orcaSessionId, - workspaceId: 'wt' - } - - await abandon(dispatchId, { - orchestrationCaller: session, - orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } - }) - expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( - 'Abandoned by orca_session_id:chat_1.' - ) - }) - - it('reports an already-settled worker as stale and changes nothing', async () => { - const dispatchId = readyWorker() - db.failDispatch(dispatchId, 'tab closed', { workerProcessExited: true }) - const before = db.getWorkerDispatch(dispatchId) - - await expect(abandon(dispatchId, {})).resolves.toMatchObject({ - state: 'failed', - alreadySettled: true, - stale: true - }) - expect(db.getWorkerDispatch(dispatchId)).toEqual(before) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts deleted file mode 100644 index 434b0ee97885..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -const PTY_ID = 'pty_worker' - -// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the -// launched agent found in front, or the shell runs it. -describe('a worker start writes its brief only into the agent it launched', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - function launchedPane(foreground: LaunchedAgentForeground): string[] { - h.setup() - const writes: string[] = [] - vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) - vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) - vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) - vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( - async (handle, text, options) => { - await options?.beforeWrite?.(PTY_ID) - writes.push(text) - return { handle, accepted: true, bytesWritten: text.length } - } - ) - return writes - } - - it('types nothing when the agent exited and its shell is in front', async () => { - const writes = launchedPane('shell') - - await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() - expect(writes).toEqual([]) - }) - - it('writes the brief once into the agent found in front', async () => { - const writes = launchedPane('agent') - - await h.startWorker({ agent: 'claude' }) - expect(writes).toHaveLength(1) - }) - - it('leaves a terminal the caller supplied to its own idle wait', async () => { - const writes = launchedPane('shell') - - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() - expect(writes).toHaveLength(1) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts index d37d0b04a594..06db0da17d84 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' @@ -60,3 +61,282 @@ describe('orchestration worker-list Run scope (runtime)', () => { ) }) }) + +// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition +// used to reject it and its abandoned Task had no documented route back. +describe('worker-start --retry-of a context-only Dispatch', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function dispatchContextOnly( + spec: string + ): Promise<{ taskId: string; dispatchId: string }> { + const task = harness.db.createTask({ spec, runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() + return { taskId: task.id, dispatchId: result.dispatch.id } + } + + it('restarts the Task after the attempt is abandoned', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') + + await expect( + harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) + expect(harness.db.getTask(taskId)?.status).toBe('blocked') + + const retried = (await harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + })) as { dispatchId: string; state: string } + + expect(retried.state).toBe('ready') + expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( + dispatchId + ) + expect(harness.db.getTask(taskId)?.status).toBe('dispatched') + }) + + it('still refuses to retry an attempt that has not settled', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('live attempt') + + await expect( + harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + }) + ).rejects.toMatchObject({ code: 'task_not_startable' }) + }) +}) + +// A coordinator that records context against its own terminal delegated nothing, so the row it +// leaves behind must not read back as the coordinator's own parent Attempt. +describe('context-only self-dispatch and nesting depth', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function selfDispatch(): Promise { + const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_coord' + })) as { dispatch: { id: string } } + return result.dispatch.id + } + + it('leaves the coordinator able to start a worker', async () => { + const selfDispatchId = await selfDispatch() + expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ + creator_handle: 'term_coord', + creator_pane_key: harness.coordinatorPaneKey + }) + + const started = await harness.startWorker({ terminal: 'term_worker' }) + + expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ + depth: 1, + creator_dispatch_id: null + }) + }) + + it('still counts a real assignment to another pane as a nesting parent', async () => { + const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) + const delegated = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + + expect( + harness.db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(1) + expect( + harness.db.resolveCreatorDispatchId({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(delegated.dispatch.id) + }) + + it('reports the self-dispatching coordinator as a root', async () => { + await selfDispatch() + + const creator = { + kind: 'terminal', + handle: 'term_coord', + paneKey: harness.coordinatorPaneKey + } as const + expect(harness.db.resolveCreatorDepth(creator)).toBe(0) + expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() + }) +}) + +type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } + +describe('orchestration worker-read fleet projection', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { + h.setup() + const { dispatchId } = await h.startWorker() + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection & { status: { liveness?: string } } + + expect(read.projection?.dispatchId).toBe(dispatchId) + // The agent verdict is not the PTY verdict; worker-read must carry both. + expect(read.status.liveness).toBe('live') + expect(read.projection?.liveness.verdict).toBe('unverifiable') + }) + + it('carries the projection on an archived read after release', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection + + expect(read.projection?.dispatchId).toBe(dispatchId) + expect(read.projection?.liveness.verdict).toBe('exited') + }) +}) + +type ListedWorker = { + dispatchId: string + workerState: string + dispatchStatus: string + projection: { + outcome: string + liveness: { verdict: string; reason?: string } + nextAction: { kind: string; argv: string[] } + attention: { categories: string[]; requiresAction: boolean } + } +} + +describe('pre-v3 dispatch rows in worker-list', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no + * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ + function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { + const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) + if (status === 'completed') { + h.db.completeDispatch(dispatch.id) + } + if (status === 'failed') { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + return dispatch.id + } + + async function listWorkers(): Promise> { + const listed = (await h.call('orchestration.workerList', { + paginate: true, + run: h.activeRunId + })) as { workers: ListedWorker[] } + return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) + } + + it('projects a settled legacy dispatch as settled with nothing to act on', async () => { + h.setup() + const completed = createLegacyDispatch('completed') + + const worker = (await listWorkers()).get(completed)! + + expect(worker.workerState).toBe('unsupervised') + expect(worker.dispatchStatus).toBe('completed') + // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` + // report or a task completion, so the durable record is the whole settlement. + expect(worker.projection.outcome).toBe('succeeded') + // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch + // that never had a worker row has no process whose absence could require action. + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'unsupervised_settled' + }) + expect(worker.projection.attention.categories).not.toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(false) + expect(worker.projection.nextAction.kind).toBe('none') + }) + + it.each(['completed', 'failed'] as const)( + 'closes a pending question when a legacy dispatch settles as %s', + async (status) => { + h.setup() + const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) + const asked = h.db.createQuestion({ + runId: h.activeRunId, + dispatchId: dispatch.id, + askerHandle: `term_legacy_q_${status}`, + question: 'Which branch?' + }) + // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. + if (status === 'completed') { + h.db.updateTaskStatus(task.id, 'completed', 'done') + } else { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + + const worker = (await listWorkers()).get(dispatch.id)! + + expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') + expect(worker.dispatchStatus).toBe(status) + expect(worker.projection.attention.categories).not.toContain('input') + // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. + expect(worker.projection.attention.requiresAction).toBe(status === 'failed') + } + ) + + it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { + h.setup() + const failed = createLegacyDispatch('failed') + + const worker = (await listWorkers()).get(failed)! + + expect(worker.dispatchStatus).toBe('failed') + expect(worker.projection.outcome).toBe('failed') + expect(worker.projection.attention.categories).toEqual(['failure']) + expect(worker.projection.attention.requiresAction).toBe(true) + }) + + it('leaves an unsettled legacy dispatch genuinely unknown', async () => { + h.setup() + const dispatched = createLegacyDispatch('dispatched') + + const worker = (await listWorkers()).get(dispatched)! + + expect(worker.projection.outcome).toBe('in_progress') + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'missing_status' + }) + expect(worker.projection.attention.categories).toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(true) + expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts deleted file mode 100644 index 3ae9c0c44d01..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } - -describe('orchestration worker-read fleet projection', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { - h.setup() - const { dispatchId } = await h.startWorker() - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection & { status: { liveness?: string } } - - expect(read.projection?.dispatchId).toBe(dispatchId) - // The agent verdict is not the PTY verdict; worker-read must carry both. - expect(read.status.liveness).toBe('live') - expect(read.projection?.liveness.verdict).toBe('unverifiable') - }) - - it('carries the projection on an archived read after release', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection - - expect(read.projection?.dispatchId).toBe(dispatchId) - expect(read.projection?.liveness.verdict).toBe('exited') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts index 9a6a399cfb16..7904c8e04723 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' +import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('workerRelease on a retained resource whose process exited', () => { @@ -102,3 +104,101 @@ describe('workerRelease on a retained resource whose process exited', () => { ) }) }) + +const READY_WAIT = { + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null +} satisfies RuntimeTerminalWait + +describe('Antigravity orchestration worker lifecycle', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { + h.setup() + const readiness = h.deferred() + vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) + + const pending = h.startWorker({ agent: 'antigravity' }) + await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) + + expect(h.runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) + ) + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ + ownership_state: 'owned', + terminal_handle: 'term_worker' + }) + + readiness.resolve(READY_WAIT) + await expect(pending).resolves.toEqual( + expect.objectContaining({ dispatchId: expect.any(String) }) + ) + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + }) + + it('stops only the owned AGY terminal', async () => { + h.setup() + const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) + + await expect( + h.call('orchestration.workerStop', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) + + it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'releasing' + }) + + await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ + attempted: 1, + released: 1 + }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + release_state: 'released' + }) + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) + expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') + }) + + it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_unknown' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts index b2627bd13d38..f6742cea93ec 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('worker-start --terminal target', () => { @@ -157,3 +159,101 @@ describe('orchestration.dispatch --to the caller', () => { expect(result.dispatch.assignee_pane_key).toBe(harness.workerPaneKey) }) }) + +const PTY_ID = 'pty_worker' + +// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the +// launched agent found in front, or the shell runs it. +describe('a worker start writes its brief only into the agent it launched', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + function launchedPane(foreground: LaunchedAgentForeground): string[] { + h.setup() + const writes: string[] = [] + vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) + vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) + vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) + vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( + async (handle, text, options) => { + await options?.beforeWrite?.(PTY_ID) + writes.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } + ) + return writes + } + + it('types nothing when the agent exited and its shell is in front', async () => { + const writes = launchedPane('shell') + + await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() + expect(writes).toEqual([]) + }) + + it('writes the brief once into the agent found in front', async () => { + const writes = launchedPane('agent') + + await h.startWorker({ agent: 'claude' }) + expect(writes).toHaveLength(1) + }) + + it('leaves a terminal the caller supplied to its own idle wait', async () => { + const writes = launchedPane('shell') + + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() + expect(writes).toHaveLength(1) + }) +}) + +describe('composer-marker first dispatch readiness', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) + ) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) + + it('keeps reused terminals on the normal idle wait', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it('never delivers a task after a startup timeout', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) + await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts deleted file mode 100644 index 79df47af820c..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -describe('composer-marker first dispatch readiness', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - it.each(['zcode', 'opencode', 'opencode2'] as const)( - '%s waits for the new composer before dispatch', - async (agent) => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - agent, - 60_000 - ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve({ - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - } - ) - - it('keeps reused terminals on the normal idle wait', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() - expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( - 'term_worker', - expect.objectContaining({ condition: 'tui-idle' }) - ) - }) - - it('never delivers a task after a startup timeout', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) - await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 687b7ba50306..3c629c58aaa0 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -12,6 +12,7 @@ import { } from '../../../../shared/protocol-version' import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../../shared/repo-search-limits' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../../shared/ssh-target-management' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -160,6 +161,49 @@ describe('repo RPC methods', () => { ) }) + it('advertises server SSH target management so clients can gate the remote project flow', () => { + expect(RUNTIME_CAPABILITIES).toContain(SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY) + }) + + // Harvested from the community PR #8492 by @jae-heo. + it('adds an existing folder on one of the server SSH targets as a project', async () => { + const repo = { + id: 'repo-p8', + path: '/srv/project', + displayName: 'project', + badgeColor: '#737373', + addedAt: 1, + connectionId: 'ssh-p8', + kind: 'git' as const + } + const runtime = new OrcaRuntimeService(null) + const addSshRepo = vi.spyOn(runtime, 'addSshRepo').mockResolvedValue(repo) + const addRepo = vi.spyOn(runtime, 'addRepo') + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.addRemote', { connectionId: 'ssh-p8', remotePath: '/srv/project' }), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(addSshRepo).toHaveBeenCalledWith({ connectionId: 'ssh-p8', remotePath: '/srv/project' }) + expect(addRepo).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: true, result: { repo: { connectionId: 'ssh-p8' } } }) + }) + + it('refuses a remote project without an SSH target rather than adding a server path', async () => { + const runtime = new OrcaRuntimeService(null) + const addSshRepo = vi.spyOn(runtime, 'addSshRepo') + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.addRemote', { remotePath: '/srv/project' }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(addSshRepo).not.toHaveBeenCalled() + }) + it('updates project runtime preferences on the runtime server', async () => { const project = { id: 'project-1', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 42563096896e..ffb9e0c6d742 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -27,6 +27,7 @@ import { RepoSparsePresetSave, RepoUpdate } from '../../../../shared/rpc-contract/repo-params' +import { RepoAddRemote } from '../../../../shared/rpc-contract/ssh-params' export const REPO_METHODS = [ defineMethod({ @@ -126,6 +127,16 @@ export const REPO_METHODS = [ ) }) }), + // Why a new method, not a field on repo.add: an old host would strip the field and register the + // path on itself instead of on its SSH target. Gated by ssh.target-management.v1. + defineMethod({ + name: 'repo.addRemote', + permission: 'workspace', + params: RepoAddRemote, + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient(await context.runtime.addSshRepo(params), context) + }) + }), defineMethod({ name: 'repo.create', permission: 'workspace', diff --git a/src/main/runtime/rpc/methods/ssh-target-management.test.ts b/src/main/runtime/rpc/methods/ssh-target-management.test.ts new file mode 100644 index 000000000000..ddba0166d402 --- /dev/null +++ b/src/main/runtime/rpc/methods/ssh-target-management.test.ts @@ -0,0 +1,304 @@ +import '../unused-default-rpc-methods.test-fixture' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { RpcCallerScope } from '../rpc-caller-scope' +import { OrcaRuntimeService } from '../../orca-runtime' +import { SSH_EDITABLE_TARGET_FIELDS } from '../../../../shared/ssh-target-management' +import { SSH_METHODS } from './ssh' + +const mocks = vi.hoisted(() => ({ + getRegisteredSshState: vi.fn(), + listRegisteredSshTargets: vi.fn(), + mutations: { add: vi.fn(), update: vi.fn(), remove: vi.fn() }, + getSshConnectionManager: vi.fn(), + browseSshDirectory: vi.fn() +})) + +vi.mock('../../../ssh/ssh-target-registry', () => ({ + connectRegisteredSshTarget: vi.fn(), + getRegisteredSshState: mocks.getRegisteredSshState, + getRegisteredSshTargetMutations: () => mocks.mutations, + getSshConnectionManager: mocks.getSshConnectionManager, + listRegisteredRemovedSshTargetLabels: vi.fn(() => ({})), + listRegisteredSshTargets: mocks.listRegisteredSshTargets +})) + +vi.mock('../../../ssh/ssh-remote-directory-browse', () => ({ + browseSshDirectory: mocks.browseSshDirectory +})) + +const SECRET_PASSWORD = 'hunter2-password' +const SECRET_PASSPHRASE = 'key-passphrase-value' + +// A stored row carrying keys no paired client may see: secrets (as a malformed or future profile +// might hold) and main-owned fields. +const storedTarget = { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 2222, + username: 'me', + identityFile: '~/.ssh/id_ed25519', + jumpHost: 'bastion', + source: 'manual', + generation: 4, + password: SECRET_PASSWORD, + passphrase: SECRET_PASSPHRASE, + lastRequiredPassphrase: true, + remoteRuntimeResolution: { rung: 'A' }, + owner: undefined +} + +const validInput = { label: 'Build', host: 'build.internal', port: 22, username: 'ci' } + +const runtime = new OrcaRuntimeService() + +function dispatch(method: string, params?: unknown, callerScope?: RpcCallerScope) { + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS, callerScope }) + const request: RpcRequest = { id: 'req-1', authToken: 'tok', method, params } + return dispatcher.dispatch(request) +} + +function expectNoSecrets(value: unknown): void { + const serialized = JSON.stringify(value) + expect(serialized).not.toContain(SECRET_PASSWORD) + expect(serialized).not.toContain(SECRET_PASSPHRASE) + expect(serialized).not.toContain('lastRequiredPassphrase') + expect(serialized).not.toContain('remoteRuntimeResolution') +} + +function listedTargetKeys(response: unknown): string[] { + const parsed: unknown = JSON.parse(JSON.stringify(response)) + if (typeof parsed !== 'object' || parsed === null || !('result' in parsed)) { + return [] + } + const { result } = parsed + if (typeof result !== 'object' || result === null || !('targets' in result)) { + return [] + } + const [first] = Array.isArray(result.targets) ? result.targets : [] + return typeof first === 'object' && first !== null ? Object.keys(first) : [] +} + +const ALLOWED_VIEW_KEYS = new Set([ + ...SSH_EDITABLE_TARGET_FIELDS, + 'id', + 'generation', + 'connected', + 'connectionStatus', + 'remotePlatform', + 'source', + 'managedServer' +]) + +describe('server SSH target management RPC', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getRegisteredSshState.mockReturnValue({ status: 'connected' }) + }) + + it('lists editable fields only; secrets and main-owned fields never leave the server', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([storedTarget]) + + const response = await dispatch('ssh.listEditableTargets') + + expect(response).toMatchObject({ + ok: true, + result: { + targets: [ + { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 2222, + username: 'me', + identityFile: '~/.ssh/id_ed25519', + jumpHost: 'bastion', + generation: 4, + connected: true, + connectionStatus: 'connected', + source: 'manual' + } + ] + } + }) + expectNoSecrets(response) + expect(listedTargetKeys(response).filter((key) => !ALLOWED_VIEW_KEYS.has(key))).toEqual([]) + }) + + it('marks a managed-server host so the client does not offer removal', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([ + { ...validInput, id: 'ssh-2', orcadFence: { environmentId: 'env-1' } } + ]) + + const response = await dispatch('ssh.listEditableTargets') + + expect(response).toMatchObject({ ok: true, result: { targets: [{ managedServer: true }] } }) + expect(JSON.stringify(response)).not.toContain('orcadFence') + }) + + it.each([ + ['password', SECRET_PASSWORD], + ['passphrase', SECRET_PASSPHRASE] + ])('refuses a %s on add or update instead of dropping it silently', async (key, value) => { + const add = await dispatch('ssh.addTarget', { target: { ...validInput, [key]: value } }) + const update = await dispatch('ssh.updateTarget', { + targetId: 'ssh-1', + target: { ...validInput, [key]: value } + }) + + expect(add).toMatchObject({ ok: false }) + expect(update).toMatchObject({ ok: false }) + expect(mocks.mutations.add).not.toHaveBeenCalled() + expect(mocks.mutations.update).not.toHaveBeenCalled() + expectNoSecrets([add, update]) + }) + + it('refuses main-owned fields a client must not forge', async () => { + const response = await dispatch('ssh.addTarget', { + target: { ...validInput, generation: 99, orcadFence: { environmentId: 'env-1' } } + }) + + expect(response).toMatchObject({ ok: false }) + expect(mocks.mutations.add).not.toHaveBeenCalled() + }) + + it('adds through the shared mutation and returns the redacted view', async () => { + mocks.mutations.add.mockReturnValue({ + target: { ...storedTarget, ...validInput, id: 'ssh-new' }, + repoReadoptions: [{ oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['r1'] }] + }) + + const response = await dispatch('ssh.addTarget', { target: validInput }) + + expect(mocks.mutations.add).toHaveBeenCalledWith({ ...validInput, source: 'manual' }) + expect(response).toMatchObject({ + ok: true, + result: { + target: { id: 'ssh-new', host: 'build.internal' }, + repoReadoptions: [{ newTargetId: 'ssh-new', repoIds: ['r1'] }] + } + }) + expectNoSecrets(response) + }) + + it('updates with replace semantics so an omitted optional field is cleared', async () => { + mocks.mutations.update.mockReturnValue({ ...storedTarget, ...validInput }) + + const response = await dispatch('ssh.updateTarget', { targetId: 'ssh-1', target: validInput }) + + const [targetId, updates] = mocks.mutations.update.mock.calls[0] + expect(targetId).toBe('ssh-1') + expect(updates).toMatchObject({ ...validInput, source: 'manual' }) + expect(Object.keys(updates)).toEqual(expect.arrayContaining(['identityFile', 'jumpHost'])) + expect(updates.identityFile).toBeUndefined() + expect(updates.jumpHost).toBeUndefined() + expect(response).toMatchObject({ ok: true }) + expectNoSecrets(response) + }) + + it('reports an unknown target instead of answering success', async () => { + mocks.mutations.update.mockReturnValue(null) + + const response = await dispatch('ssh.updateTarget', { targetId: 'gone', target: validInput }) + + expect(response).toMatchObject({ ok: false, error: { message: 'SSH target not found: gone' } }) + }) + + it('removes through the shared mutation and surfaces its refusal', async () => { + mocks.mutations.remove.mockResolvedValueOnce(undefined) + await expect(dispatch('ssh.removeTarget', { targetId: 'ssh-1' })).resolves.toMatchObject({ + ok: true, + result: { removed: true } + }) + mocks.mutations.remove.mockRejectedValueOnce(new Error('This host runs a managed Orca server.')) + await expect(dispatch('ssh.removeTarget', { targetId: 'ssh-2' })).resolves.toMatchObject({ + ok: false + }) + }) + + it('browses a server SSH target over the server connection manager', async () => { + const manager = { getConnection: vi.fn() } + mocks.getSshConnectionManager.mockReturnValue(manager) + mocks.browseSshDirectory.mockResolvedValue({ + entries: [{ name: 'src', isDirectory: true }], + resolvedPath: '/home/me', + pathFlavor: 'posix' + }) + + const response = await dispatch('ssh.browseDir', { targetId: 'ssh-1', dirPath: '~' }) + + expect(mocks.browseSshDirectory).toHaveBeenCalledWith(manager, 'ssh-1', '~') + expect(response).toMatchObject({ ok: true, result: { resolvedPath: '/home/me' } }) + }) + + it('publishes no client event carrying target data', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([storedTarget]) + mocks.mutations.add.mockReturnValue({ target: storedTarget, repoReadoptions: [] }) + mocks.mutations.update.mockReturnValue(storedTarget) + mocks.mutations.remove.mockResolvedValue(undefined) + + const events: unknown[] = [] + const unsubscribe = runtime.onClientEvent((event) => events.push(event)) + + await dispatch('ssh.listEditableTargets') + await dispatch('ssh.addTarget', { target: validInput }) + await dispatch('ssh.updateTarget', { targetId: 'ssh-1', target: validInput }) + await dispatch('ssh.removeTarget', { targetId: 'ssh-1' }) + unsubscribe() + + expectNoSecrets(events) + expect(JSON.stringify(events)).not.toContain('dev.internal') + }) + + describe('who may manage', () => { + const MANAGEMENT_CALLS: [string, unknown][] = [ + ['ssh.listEditableTargets', undefined], + ['ssh.addTarget', { target: validInput }], + ['ssh.updateTarget', { targetId: 'ssh-1', target: validInput }], + ['ssh.removeTarget', { targetId: 'ssh-1' }], + ['ssh.browseDir', { targetId: 'ssh-1', dirPath: '~' }] + ] + + beforeEach(() => { + mocks.listRegisteredSshTargets.mockReturnValue([]) + mocks.mutations.add.mockReturnValue({ target: storedTarget, repoReadoptions: [] }) + mocks.mutations.update.mockReturnValue(storedTarget) + mocks.mutations.remove.mockResolvedValue(undefined) + mocks.browseSshDirectory.mockResolvedValue({ + entries: [], + resolvedPath: '/', + pathFlavor: 'posix' + }) + }) + + it.each(MANAGEMENT_CALLS)( + 'a paired desktop or web client may call %s', + async (method, params) => { + await expect( + dispatch(method, params, { kind: 'runtime-paired', grants: [] }) + ).resolves.toMatchObject({ ok: true }) + } + ) + + it.each(MANAGEMENT_CALLS)('a phone may not call %s', async (method, params) => { + await expect(dispatch(method, params, { kind: 'mobile' })).resolves.toMatchObject({ + ok: false + }) + }) + + it.each(MANAGEMENT_CALLS)( + "an SSH host's CLI without remote control may not call %s", + async (method, params) => { + await expect( + dispatch(method, params, { + kind: 'ssh-bridge', + targetId: 'ssh-1', + remoteCliControl: false + }) + ).resolves.toMatchObject({ ok: false }) + } + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 0f2e0b606c9d..e2bce335a98b 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,28 +1,75 @@ import { connectRegisteredSshTarget, getRegisteredSshState, + getRegisteredSshTargetMutations, + getSshConnectionManager, listRegisteredRemovedSshTargetLabels, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' +import { isManagedOrcadSshTarget } from '../../../ssh/ssh-connection-store' +import { browseSshDirectory } from '../../../ssh/ssh-remote-directory-browse' import { defineMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' -import type { SshTargetSummary } from '../../../../shared/ssh-types' -import { SshTarget } from '../../../../shared/rpc-contract/ssh-params' +import type { SshTarget as SshTargetRecord, SshTargetSummary } from '../../../../shared/ssh-types' +import type { + SshEditableTarget, + SshEditableTargetField, + SshEditableTargetInput +} from '../../../../shared/ssh-target-management' +import { + SshBrowseDir, + SshTarget, + SshTargetAdd, + SshTargetUpdate +} from '../../../../shared/rpc-contract/ssh-params' // Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone. +function toSshTargetSummary({ id, label, generation }: SshTargetRecord): SshTargetSummary { + const state = getRegisteredSshState(id) + const remotePlatform = state?.remotePlatform + return { + id, + label, + ...(generation === undefined ? {} : { generation }), + connected: state?.status === 'connected', + ...(state?.status === undefined ? {} : { connectionStatus: state.status }), + ...(remotePlatform === undefined ? {} : { remotePlatform }) + } +} + function listRegisteredSshTargetSummaries(): SshTargetSummary[] { - return listRegisteredSshTargets().map(({ id, label, generation }) => { - const state = getRegisteredSshState(id) - const remotePlatform = state?.remotePlatform - return { - id, - label, - ...(generation === undefined ? {} : { generation }), - connected: state?.status === 'connected', - ...(state?.status === undefined ? {} : { connectionStatus: state.status }), - ...(remotePlatform === undefined ? {} : { remotePlatform }) - } - }) + return listRegisteredSshTargets().map(toSshTargetSummary) +} + +// Why an allowlist: a field added to SshTarget later (or a stray key in an old profile) must not +// reach paired clients by default. `satisfies` keeps it in step with SSH_EDITABLE_TARGET_FIELDS. +function pickSshEditableFields(source: SshEditableTargetInput): SshEditableTargetInput { + return { + label: source.label, + configHost: source.configHost, + host: source.host, + port: source.port, + username: source.username, + identityFile: source.identityFile, + identityAgent: source.identityAgent, + identitiesOnly: source.identitiesOnly, + gssapiAuthentication: source.gssapiAuthentication, + proxyCommand: source.proxyCommand, + jumpHost: source.jumpHost, + relayGracePeriodSeconds: source.relayGracePeriodSeconds, + systemSshConnectionReuse: source.systemSshConnectionReuse, + remoteRuntime: source.remoteRuntime, + allowRemoteCliControl: source.allowRemoteCliControl + } satisfies Record +} + +function toSshEditableTarget(target: SshTargetRecord): SshEditableTarget { + return { + ...pickSshEditableFields(target), + ...toSshTargetSummary(target), + ...(target.source === undefined ? {} : { source: target.source }), + ...(isManagedOrcadSshTarget(target) ? { managedServer: true as const } : {}) + } } export const SSH_METHODS = [ @@ -66,5 +113,57 @@ export const SSH_METHODS = [ permission: 'workspace', params: null, handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) + }), + // Why `workspace`: a paired client that can open terminals here can already edit ~/.ssh/config, + // so managing this host's SSH targets is the same power (rpc-method-permission.ts). + defineMethod({ + name: 'ssh.listEditableTargets', + permission: 'workspace', + params: null, + handler: () => ({ targets: listRegisteredSshTargets().map(toSshEditableTarget) }) + }), + defineMethod({ + name: 'ssh.addTarget', + permission: 'workspace', + params: SshTargetAdd, + handler: (params) => { + const result = getRegisteredSshTargetMutations().add({ ...params.target, source: 'manual' }) + return { + target: toSshEditableTarget(result.target), + repoReadoptions: result.repoReadoptions + } + } + }), + defineMethod({ + name: 'ssh.updateTarget', + permission: 'workspace', + params: SshTargetUpdate, + handler: (params) => { + // Why: replace semantics, so an omitted optional field is cleared, as SSH settings do. + const updated = getRegisteredSshTargetMutations().update(params.targetId, { + ...pickSshEditableFields(params.target), + source: 'manual' + }) + if (!updated) { + throw new Error(`SSH target not found: ${params.targetId}`) + } + return { target: toSshEditableTarget(updated) } + } + }), + defineMethod({ + name: 'ssh.removeTarget', + permission: 'workspace', + params: SshTarget, + handler: async (params) => { + await getRegisteredSshTargetMutations().remove(params.targetId) + return { removed: true } + } + }), + defineMethod({ + name: 'ssh.browseDir', + permission: 'workspace', + params: SshBrowseDir, + handler: (params) => + browseSshDirectory(getSshConnectionManager(), params.targetId, params.dirPath) }) ] diff --git a/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts b/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts new file mode 100644 index 000000000000..b557ef2e33cd --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts @@ -0,0 +1,21 @@ +import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' +import { WorktreeAdoptProvisionedRoot } from '../../../../shared/rpc-contract/worktree-provisioned-root-params' +import { defineMethod } from '../core' + +export const WORKTREE_PROVISIONED_ROOT_METHOD = defineMethod({ + name: 'worktree.adoptProvisionedRoot', + permission: 'workspace', + params: WorktreeAdoptProvisionedRoot, + handler: async (params, { runtime }) => { + const { repo, linkedIssue, linkedPR, linkedGitLabMR, linkedGitLabIssue, ...request } = params + // Why: null clears a link on update; a fresh adoption has nothing to clear. + const result = await runtime.adoptManagedProvisionedRoot(repo, { + ...request, + linkedIssue: linkedIssue ?? undefined, + linkedPR: linkedPR ?? undefined, + linkedGitLabMR: linkedGitLabMR ?? undefined, + linkedGitLabIssue: linkedGitLabIssue ?? undefined + }) + return { ...result, catalogVersion: getLocalWorktreeCatalogVersion(result.worktree.repoId) } + } +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 9d044842673c..bd2b4d7f5c36 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -30,6 +30,7 @@ import { WorktreeTeardownMissingTerminalsParams } from '../../../../shared/rpc-contract/worktree-params' import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' +import { WORKTREE_PROVISIONED_ROOT_METHOD } from './worktree-provisioned-root-method' import { readsWorktreeRemovalMarker } from '../worktree-removal-marker-projection' export const WORKTREE_METHODS = [ @@ -136,6 +137,7 @@ export const WORKTREE_METHODS = [ } }) }), + WORKTREE_PROVISIONED_ROOT_METHOD, defineMethod({ name: 'worktree.prefetchCreateBase', permission: 'workspace', diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index 95c85720f78b..e7ac814bf032 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' -import { ORCHESTRATION_TARGET_PARAM } from '../orchestration/orchestration-party' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -100,20 +99,6 @@ function assignTerminal(runId: string): void { } describe('every target param resolves both spellings of a party to one canonical address', () => { - it('covers exactly the target params the contract lists', () => { - expect(Object.keys(ORCHESTRATION_TARGET_PARAM).sort()).toEqual( - [ - 'orchestration.send', - 'orchestration.ask', - 'orchestration.dispatch', - 'orchestration.inbox', - 'orchestration.sessionAddress', - 'orchestration.partyLocation', - 'orchestration.workerStart' - ].sort() - ) - }) - it.each(WORKER_SPELLINGS)('send: a worker at %s gets its Dispatch mailbox', async (_l, to) => { const dispatchId = assignWorker(await chatRun()) const { message } = await as(undefined, 'orchestration.send', { diff --git a/src/main/runtime/rpc/rpc-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts index b44b8d8c2a26..ecb1a0fb4308 100644 --- a/src/main/runtime/rpc/rpc-caller-scope.ts +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -29,7 +29,9 @@ export const OWNER_RPC_CALLER_SCOPE: RpcCallerScope = { kind: 'owner' } // Why: a paired desktop, web client or `--host runtime:` CLI is this runtime's full remote UI, so its // standard set covers what that UI drives here (settings, accounts, skills, SSH and server updates). -// Everything else (desktop control, pairing and push admin) is refused unless granted at pairing. +// None of these exceeds `workspace`, which already runs commands as the user (see +// rpc-method-permission.ts). Desktop control needs a grant at pairing; pairing admin is never +// granted to this row. const RUNTIME_PAIRED_STANDARD: ReadonlySet = new Set([ 'workspace', 'settings-write', diff --git a/src/main/runtime/rpc/rpc-method-permission.ts b/src/main/runtime/rpc/rpc-method-permission.ts index 43d06460f62d..bc4ab5270f0b 100644 --- a/src/main/runtime/rpc/rpc-method-permission.ts +++ b/src/main/runtime/rpc/rpc-method-permission.ts @@ -3,9 +3,18 @@ * * The dispatcher grants each caller kind a set of these (rpc-caller-scope.ts), so a new method * cannot ship without someone deciding which callers may reach it. + * + * `workspace` means the caller can run code as this host's user: it opens terminals and starts + * agents here. Anything a terminal command could do anyway (browsing directories, installing + * skills, changing launch env or SSH targets) is therefore not a boundary for a `workspace` + * caller. Those stay separate tiers so a narrower row (an SSH host's CLI) can refuse them; mobile + * is gated by its own method-name allowlist instead. For a paired runtime client the real + * boundaries are `desktop-control` (granted per device at pairing) and `pairing-admin` (never + * granted to it). */ export type RpcMethodPermission = - /** Projects, worktrees, terminals, files, git, browser, orchestration and integrations. */ + /** Projects, worktrees, terminals, files, git, browser, orchestration and integrations; equals + * running commands as this host's user. */ | 'workspace' /** Drives this machine's own desktop: clicks, keys and app state outside Orca. */ | 'desktop-control' diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts index a5f2dc82793a..cbc7f4e3d1f7 100644 --- a/src/main/runtime/runtime-durable-store-fixture.ts +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -1,5 +1,14 @@ import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' +import { resolveHostId } from '../persistence/loading-store/session-host-partitions' +import { + admitPaneToSession, + withdrawPaneFromSession, + type PaneLayoutChange, + type TerminalPaneAdmission +} from '../persistence/terminal-topology/terminal-pane-admission' +import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' /** Keep runtime fakes on the same reserved, durable-before-ack contract as Store. */ export function withDurableRuntimeStore< @@ -38,3 +47,29 @@ export function withDurableRuntimeStore< } }) } + +/** Gives a runtime fake the Store's pane admission, over the fake's own session accessors. */ +export function withRuntimePaneAdmission< + T extends { + getWorkspaceSession?: (hostId?: string) => WorkspaceSessionState + setWorkspaceSession?: (session: WorkspaceSessionState, hostId?: string) => void + } +>(store: T) { + const commit = ( + hostId: string | undefined, + change: (session: WorkspaceSessionState) => PaneLayoutChange + ): V => { + const resolved = resolveHostId(hostId) + const { value, session } = change(store.getWorkspaceSession!(resolved)) + if (session) { + store.setWorkspaceSession!(session, resolved) + } + return value + } + return Object.assign(store, { + admitTerminalPane: async (admission: TerminalPaneAdmission, hostId?: string) => + commit(hostId, (session) => admitPaneToSession(resolveHostId(hostId), session, admission)), + withdrawTerminalPane: async (pane: CommandOf<'closePane'>, hostId?: string) => + commit(hostId, (session) => withdrawPaneFromSession(resolveHostId(hostId), session, pane)) + }) +} diff --git a/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts b/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts deleted file mode 100644 index 9f2650af743b..000000000000 --- a/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { IFilesystemProvider } from '../providers/types' -import { - createSshDisposalError, - SSH_MUX_REQUEST_TIMEOUT_CODE -} from '../ssh/ssh-channel-multiplexer' -import { createSshFileExplorerWatchTestConnection as createWatchConnection } from './ssh-file-explorer-watch-test-connection' -import { armSshFileExplorerWatchRearm } from './runtime-file-commands-ssh-file-watcher-rearm' - -type WatchProvider = Pick - -const { getProvider, registrationListeners, rearms } = vi.hoisted(() => ({ - getProvider: vi.fn<(connectionId: string) => WatchProvider | undefined>(), - registrationListeners: new Set<(connectionId: string) => void>(), - rearms: new Map void>>() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getProvider, - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - registrationListeners.add(listener) - return () => registrationListeners.delete(listener) - } -})) - -vi.mock('./runtime-file-commands-mobile-file-list-limit', () => ({ - runtimeWatcherReleaseKey: (runtimeId: string, connectionId: string, rootPath: string) => - `${runtimeId}:${connectionId}:${rootPath}`, - sshFileExplorerWatchRearms: rearms -})) - -function registerProvider(provider: WatchProvider): void { - getProvider.mockReturnValue(provider) - for (const listener of registrationListeners) { - listener('ssh-1') - } -} - -function pendingWatch() { - let resolve: (unwatch: () => void) => void = () => undefined - let reject: (error: Error) => void = () => undefined - const promise = new Promise<() => void>((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { watch: vi.fn(() => promise), resolve, reject } -} - -describe('SSH file explorer watcher rearm', () => { - let unsubscribe: (() => Promise) | undefined - const connections: ReturnType[] = [] - - beforeEach(() => { - getProvider.mockReset() - registrationListeners.clear() - rearms.clear() - }) - - afterEach(async () => { - for (const { mux } of connections) { - mux.dispose() - } - await unsubscribe?.() - unsubscribe = undefined - for (const { provider } of connections.splice(0)) { - provider.dispose() - } - expect(registrationListeners.size).toBe(0) - expect(rearms.size).toBe(0) - }) - - function install( - initialProvider: WatchProvider = { watch: vi.fn() }, - initialUnwatch: () => void = vi.fn() - ) { - if (!getProvider.getMockImplementation()) { - getProvider.mockReturnValue(initialProvider) - } - const onEvents = vi.fn() - const controller = new AbortController() - const onTerminalError = vi.fn((_error: Error) => controller.abort()) - const rearm = armSshFileExplorerWatchRearm({ - runtimeId: 'runtime-1', - connectionId: 'ssh-1', - rootPath: '/remote/repo', - callback: onEvents, - onTerminalError, - signal: controller.signal, - initialUnwatch, - initialProvider - }) - unsubscribe = rearm.unsubscribe - return { onEvents, onTerminalError, initialUnwatch, controller } - } - - it('ignores a superseded setup failure and installs the current provider', async () => { - const { onEvents, onTerminalError, controller } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - old.reject(new Error('previous transport disconnected')) - - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - expect(onEvents).toHaveBeenCalledExactlyOnceWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - }) - - it('replaces an initial watch whose provider changed before rearm was installed', async () => { - const original = pendingWatch() - const initialUnwatch = vi.fn() - const initialSetup = original.watch('/remote/repo', vi.fn()) - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - original.resolve(initialUnwatch) - await initialSetup - const { onEvents, onTerminalError } = install(original, initialUnwatch) - - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - expect(current.watch).toHaveBeenCalledOnce() - expect(initialUnwatch).not.toHaveBeenCalled() - expect(onTerminalError).not.toHaveBeenCalled() - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - expect(initialUnwatch).not.toHaveBeenCalled() - }) - - it('coalesces registrations that arrive before replacement setup starts', async () => { - const { onEvents } = install() - const old = { watch: vi.fn(async () => vi.fn()) } - const current = { watch: vi.fn(async () => vi.fn()) } - registerProvider(old) - registerProvider(current) - - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - expect(old.watch).not.toHaveBeenCalled() - expect(current.watch).toHaveBeenCalledOnce() - }) - - it('closes a superseded successful setup without publishing its refresh', async () => { - const { onEvents, onTerminalError } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - const obsoleteUnwatch = vi.fn() - old.resolve(obsoleteUnwatch) - - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(obsoleteUnwatch).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(onEvents).toHaveBeenCalledOnce() - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - }) - - it('reports a genuine current-provider setup failure', async () => { - const { onTerminalError, onEvents } = install() - const error = new Error('current host refused watch') - registerProvider({ - watch: vi.fn(async () => { - throw error - }) - }) - - await vi.waitFor(() => expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(error)) - expect(onEvents).not.toHaveBeenCalled() - }) - - it.each(['setup', 'terminal callback'] as const)( - 'waits for registration after a current-provider %s loses its connection', - async (stage) => { - const { onTerminalError, onEvents, controller } = install() - const lost = pendingWatch() - registerProvider(lost) - await vi.waitFor(() => expect(lost.watch).toHaveBeenCalledOnce()) - const error = createSshDisposalError('connection_lost') - if (stage === 'setup') { - lost.reject(error) - } else { - lost.resolve(vi.fn()) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - lost.watch.mock.calls[0]?.[2]?.onTerminalError?.(error) - } - await new Promise((resolve) => setImmediate(resolve)) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(onEvents).toHaveBeenLastCalledWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - } - ) - - it.each([ - ['shutdown', createSshDisposalError('shutdown')], - [ - 'timeout', - Object.assign(new Error('watch timed out'), { code: SSH_MUX_REQUEST_TIMEOUT_CODE }) - ], - ['uncoded connection message', new Error('CONNECTION_LOST')] - ])('reports a current-provider %s setup failure', async (_label, error) => { - const { onTerminalError } = install() - registerProvider({ - watch: vi.fn(async () => { - throw error - }) - }) - await vi.waitFor(() => expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(error)) - }) - - it.each(['active', 'canceled'] as const)( - 'keeps an %s owner correct through actual multiplexer loss and later registration', - async (owner) => { - const connect = () => { - const connection = createWatchConnection() - connections.push(connection) - return connection - } - const first = connect() - getProvider.mockReturnValue(first.provider) - const initialSetup = first.provider.watch('/remote/repo', vi.fn()) - first.settleWatch() - const { onEvents, onTerminalError, controller } = install(first.provider, await initialSetup) - first.mux.dispose('connection_lost') - first.provider.dispose() - const next = connect() - registerProvider(next.provider) - await vi.waitFor(() => expect(next.countRequests('fs.watch')).toBe(1)) - next.mux.dispose('connection_lost') - await new Promise((resolve) => setImmediate(resolve)) - expect(getProvider('ssh-1')).toBe(next.provider) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - expect(onEvents).not.toHaveBeenCalled() - if (owner === 'canceled') { - await unsubscribe?.() - } - - next.provider.dispose() - const current = connect() - registerProvider(current.provider) - if (owner === 'canceled') { - await new Promise((resolve) => setImmediate(resolve)) - expect(current.countRequests('fs.watch')).toBe(0) - return - } - await vi.waitFor(() => expect(current.countRequests('fs.watch')).toBe(1)) - current.settleWatch() - await vi.waitFor(() => - expect(onEvents).toHaveBeenCalledExactlyOnceWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - ) - current.emitChange() - expect(onEvents).toHaveBeenLastCalledWith([ - { kind: 'update', absolutePath: '/remote/repo/current.ts' } - ]) - await unsubscribe?.() - expect(current.countRequests('fs.unwatch')).toBe(1) - current.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - } - ) - - it.each(['unregistered', 'replaced'] as const)( - 'ignores a setup failure when its provider is %s between rejection handlers', - async (change) => { - const { onTerminalError, onEvents, controller } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const current = { watch: vi.fn(async () => vi.fn()) } - const checkedWhileCurrent = vi.fn(() => { - queueMicrotask(() => { - if (change === 'replaced') { - registerProvider(current) - } else { - getProvider.mockReturnValue(undefined) - } - }) - return old - }) - getProvider.mockImplementationOnce(checkedWhileCurrent) - old.reject(new Error('previous setup failed')) - await new Promise((resolve) => setImmediate(resolve)) - - expect(checkedWhileCurrent).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - if (change === 'replaced') { - expect(current.watch).toHaveBeenCalledOnce() - expect(onEvents).toHaveBeenCalledExactlyOnceWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - } else { - expect(onEvents).not.toHaveBeenCalled() - } - } - ) - - it('ignores late terminal callbacks from a superseded provider', async () => { - const { onTerminalError, controller } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const current = { watch: vi.fn(async () => vi.fn()) } - registerProvider(current) - old.watch.mock.calls[0]?.[2]?.onTerminalError?.(new Error('previous watcher stopped')) - old.resolve(vi.fn()) - - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - }) - - it('reports a terminal callback from the current provider', async () => { - const { onTerminalError, onEvents } = install() - const current = pendingWatch() - registerProvider(current) - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - current.resolve(vi.fn()) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - const error = new Error('current watcher stopped') - current.watch.mock.calls[0]?.[2]?.onTerminalError?.(error) - - expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(error) - }) - - it('ignores late events from a superseded provider and delivers current events', async () => { - const { onEvents } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const current = pendingWatch() - registerProvider(current) - old.watch.mock.calls[0]?.[1]([{ kind: 'update', absolutePath: '/remote/repo/old.ts' }]) - old.resolve(vi.fn()) - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - current.resolve(vi.fn()) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - const currentEvents = [{ kind: 'update' as const, absolutePath: '/remote/repo/current.ts' }] - current.watch.mock.calls[0]?.[1](currentEvents) - - expect(onEvents).toHaveBeenCalledTimes(2) - expect(onEvents).toHaveBeenLastCalledWith(currentEvents) - }) - - it('releases the previous subscriber when the same provider is registered again', async () => { - const { onEvents } = install() - const callbacks = new Set[1]>() - const provider = { - watch: vi.fn(async (_root, callback) => { - callbacks.add(callback) - return () => { - callbacks.delete(callback) - } - }) - } - registerProvider(provider) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - registerProvider(provider) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledTimes(2)) - - expect(callbacks.size).toBe(1) - const events = [{ kind: 'update' as const, absolutePath: '/remote/repo/current.ts' }] - for (const callback of callbacks) { - callback(events) - } - expect(onEvents).toHaveBeenCalledTimes(3) - expect(onEvents).toHaveBeenLastCalledWith(events) - await unsubscribe?.() - expect(callbacks.size).toBe(0) - }) - - it('does not report a setup failure after unsubscribe starts', async () => { - const { onTerminalError, onEvents, initialUnwatch } = install() - const pending = pendingWatch() - registerProvider(pending) - await vi.waitFor(() => expect(pending.watch).toHaveBeenCalledOnce()) - const closed = unsubscribe?.() - pending.reject(new Error('setup canceled during shutdown')) - - await closed - expect(initialUnwatch).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(onEvents).not.toHaveBeenCalled() - }) - - it('closes a replacement that finishes after unsubscribe starts', async () => { - const { onTerminalError, onEvents } = install() - const pending = pendingWatch() - registerProvider(pending) - await vi.waitFor(() => expect(pending.watch).toHaveBeenCalledOnce()) - const closed = unsubscribe?.() - const lateUnwatch = vi.fn() - pending.resolve(lateUnwatch) - - await closed - expect(lateUnwatch).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(onEvents).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/runtime-file-path-existence.test.ts b/src/main/runtime/runtime-file-path-existence.test.ts deleted file mode 100644 index 24b12e7d5a6b..000000000000 --- a/src/main/runtime/runtime-file-path-existence.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import './rpc/unused-default-rpc-methods.test-fixture' -import { afterEach, expect, it, vi } from 'vitest' -import { mkdtemp, writeFile, rm } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { RuntimeFileCommands } from './orca-runtime-files' -import { RpcDispatcher } from './rpc/dispatcher' -import { FILE_METHODS } from './rpc/methods/files' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { pathsExistOnRelay } from '../../relay/fs-path-existence' -import { statRelayPath } from '../../relay/fs-path-metadata-requests' -let root: string | undefined -const connection = 'batch-fixture-host' -afterEach(async () => { - unregisterSshFilesystemProvider(connection) - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = undefined -}) -async function setup(legacy = false) { - root = await mkdtemp(join(tmpdir(), 'orca-runtime-batch-')) - const names = Array.from({ length: 8 }, (_, i) => `file-${i}.ts`) - await Promise.all(names.map((name) => writeFile(join(root!, name), 'fixture'))) - const provider = { - pathsExist: legacy - ? undefined - : vi.fn((paths: string[]) => pathsExistOnRelay({ filePaths: paths })), - stat: vi.fn((filePath: string) => statRelayPath({ filePath })) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registered fixture implements the stat and optional batch operations exercised here. - registerSshFilesystemProvider(connection, provider as never) - const resolveTarget = vi.fn(async () => ({ - worktree: { id: 'folder-1', path: root, kind: 'folder', repoId: 'folder-repo' }, - executionHostId: `ssh:${connection}` - })) - const host = { - getRuntimeId: () => 'runtime-fixture', - requireStore: vi.fn(() => { - throw new Error('Local store should not be read') - }), - resolveRuntimeFileTarget: resolveTarget - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies runtime identity, target resolution and the guarded store accessor used by these reads. - const commands = new RuntimeFileCommands(host as never) - const runtime = { - getRuntimeId: host.getRuntimeId, - pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands), - statRuntimeFile: commands.statRuntimeFile.bind(commands) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatch is limited to the two file methods implemented by this fixture. - const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: FILE_METHODS }) - const dispatch = (relativePaths: string[]) => - dispatcher.dispatch({ - id: 'batch-1', - authToken: 'fixture', - method: 'files.pathsExist', - params: { worktree: 'id:folder-1', relativePaths } - }) - return { names, provider, resolveTarget, host, dispatch } -} -it('actual RPC dispatch resolves one folder owner and sends one provider batch for eight real files', async () => { - const f = await setup() - expect(await f.dispatch(f.names)).toMatchObject({ - ok: true, - result: f.names.map(() => ({ exists: true })) - }) - expect(f.resolveTarget).toHaveBeenCalledTimes(1) - expect(f.resolveTarget).toHaveBeenCalledWith('id:folder-1') - expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) - expect(f.provider.stat).not.toHaveBeenCalled() - expect(f.host.requireStore).not.toHaveBeenCalled() - expect(await f.dispatch(['../escape'])).toMatchObject({ ok: false }) - expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) -}) -it('legacy provider preserves all answers through scoped scalar stats', async () => { - const f = await setup(true) - expect(await f.dispatch([...f.names, 'missing'])).toMatchObject({ - ok: true, - result: [...f.names.map(() => ({ exists: true })), { exists: false }] - }) - expect(f.provider.stat).toHaveBeenCalledTimes(9) - expect(f.host.requireStore).not.toHaveBeenCalled() -}) -it('unavailable SSH never falls back to matching local files; oversized input never reaches provider', async () => { - const f = await setup() - unregisterSshFilesystemProvider(connection) - expect(await f.dispatch(f.names)).toMatchObject({ - ok: false, - error: { message: expect.stringContaining('Remote connection dropped') } - }) - expect(f.host.requireStore).not.toHaveBeenCalled() - expect(await f.dispatch(Array(129).fill('file-0.ts'))).toMatchObject({ ok: false }) - expect(f.provider.pathsExist).not.toHaveBeenCalled() -}) diff --git a/src/main/runtime/runtime-file-upload-decode.test.ts b/src/main/runtime/runtime-file-upload-decode.test.ts index e70012990a78..cc018eeecf92 100644 --- a/src/main/runtime/runtime-file-upload-decode.test.ts +++ b/src/main/runtime/runtime-file-upload-decode.test.ts @@ -1,7 +1,6 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeFileCommandHost } from './runtime-file-command-host' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ resolvePath: vi.fn(), @@ -33,7 +32,6 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file' import { createLocalFilesystemProvider } from '../providers/local-filesystem-provider' -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' function unexpectedHostCall(): never { throw new Error('Unexpected access beyond the isolated file command') @@ -53,16 +51,6 @@ const destination = join('workspace', 'uploads', 'binary.dat') const target = { executionHostId: 'local', path: destination } let remote = false -function sshProvider(): SshFilesystemProvider { - remote = true - mocks.resolvePath.mockResolvedValue({ ...target, executionHostId: 'ssh:target' }) - const mux = { onNotification: vi.fn(() => () => {}), request: unexpectedHostCall } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: construction only subscribes; writes use the injected raw transfer and any mux request fails. - return new SshFilesystemProvider('target', mux as unknown as SshChannelMultiplexer, undefined, { - writeBuffer: mocks.writeBuffer - }) -} - beforeEach(() => { vi.resetAllMocks() remote = false @@ -152,43 +140,6 @@ describe.each(['whole', 'first', 'append'] as const)('runtime %s base64 write', } ) - it.each(['', 'AAH+/w==', 'AA', '%%%'])( - 'decodes once through the SSH provider for %j', - async (base64) => { - const expected = Buffer.from(base64, 'base64') - const provider = sshProvider() - mocks.provider.mockReturnValue(provider) - const decode = vi.spyOn(Buffer, 'from') - await expect(write(base64)).resolves.toEqual({ ok: true }) - expect( - decode.mock.calls.filter((args) => args.at(0) === base64 && args.at(1) === 'base64') - ).toHaveLength(1) - expect(mocks.expectation).toHaveBeenCalledWith('ssh:target', 'ssh:target', 'target', 7) - expect(mocks.writeBuffer).toHaveBeenCalledWith(destination, expected, { - append: mode === 'append', - exclusive: mode !== 'append' - }) - expect(mocks.authorize).not.toHaveBeenCalled() - expect(mocks.mkdir).not.toHaveBeenCalled() - expect(mocks.writeFile).not.toHaveBeenCalled() - provider.dispose() - } - ) - - it('forwards every full upload byte with one matching base64 Buffer decode', async () => { - mocks.provider.mockReturnValue(sshProvider()) - const bytes = Buffer.alloc(384 * 1024, 0xb7) - const base64 = bytes.toString('base64') - const decode = vi.spyOn(Buffer, 'from') - await write(base64) - expect( - decode.mock.calls.filter((args) => args.at(0) === base64 && args.at(1) === 'base64') - ).toHaveLength(1) - expect(Buffer.isBuffer(mocks.writeBuffer.mock.calls[0][1])).toBe(true) - expect(mocks.writeBuffer.mock.calls[0][1]).toHaveLength(bytes.length) - expect(Buffer.prototype.equals.call(mocks.writeBuffer.mock.calls[0][1], bytes)).toBe(true) - }) - it.each(['resolve', 'expectation', 'provider'] as const)( 'preserves %s failure before decoding or writes', async (stage) => { @@ -231,14 +182,4 @@ describe.each(['whole', 'first', 'append'] as const)('runtime %s base64 write', } } ) - - it('propagates SSH sink errors without a local fallback', async () => { - mocks.provider.mockReturnValue(sshProvider()) - const failure = new Error('SSH sink failed') - mocks.writeBuffer.mockRejectedValue(failure) - await expect(write('AA==')).rejects.toBe(failure) - expect(mocks.authorize).not.toHaveBeenCalled() - expect(mocks.mkdir).not.toHaveBeenCalled() - expect(mocks.writeFile).not.toHaveBeenCalled() - }) }) diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 973da646e87c..6e7edb3c5088 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -31,7 +31,12 @@ export type RuntimeNotifier = { selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } reason?: 'definition' | 'run' | 'usage' }): void - worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void + worktreesChanged( + repoId: string, + renamed?: { oldWorktreeId: string; newWorktreeId: string }, + // Why: false for metadata-only changes (sort order) that leave Git scan results valid. + options?: { invalidateGitScans?: boolean } + ): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts new file mode 100644 index 000000000000..16ca7e224059 --- /dev/null +++ b/src/main/runtime/runtime-pane-admission.ts @@ -0,0 +1,87 @@ +import { toSshExecutionHostId } from '../../shared/execution-host' +import type { TerminalPaneAdmission } from '../persistence/terminal-topology/terminal-pane-admission' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import type { RuntimeStore } from './runtime-store-contract' + +type Spawn = NonNullable +type AdmittedSpawnArgs = Parameters[0] & { + worktreeId: string + tabId: string + leafId: string + /** The group a new tab joins; a split stays in its parent's. */ + groupId?: string +} + +/** The pane its placement names: a new tab, or a split beside its parent pane. */ +function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission { + const { worktreeId: workspace, tabId, leafId, placement } = args + if (placement?.kind === 'split') { + const { parentLeafId, direction } = placement + return { + type: 'splitPane', + workspace, + tabId, + leafId: parentLeafId, + direction, + newLeafId: leafId + } + } + const viewMode = placement?.kind === 'new-tab' ? placement.row?.viewMode : undefined + return { + type: 'createTerminalTab', + workspace, + tabId, + leafId, + ...(args.groupId ? { groupId: args.groupId } : {}), + ...(viewMode ? { viewMode } : {}), + ...(args.cwd ? { creation: { startupCwd: args.cwd } } : {}) + } +} + +function hostIdOf(args: AdmittedSpawnArgs): string | undefined { + return args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined +} + +/** Takes back a pane the runtime wrote for a start that then failed, so it leaves no tab. */ +export async function withdrawRuntimePane( + store: RuntimeStore | null | undefined, + args: AdmittedSpawnArgs +): Promise { + await store?.withdrawTerminalPane?.( + { type: 'closePane', workspace: args.worktreeId, tabId: args.tabId, leafId: args.leafId }, + hostIdOf(args) + ) +} + +/** + * Starts a runtime-originated terminal (design 4.1): its pane, tab-bar entry and group are written + * first, then the process starts and binds to that pane. A start that fails, or that attaches to a + * session already running in another pane, takes back the pane it wrote. + */ +export async function spawnInAdmittedPane( + store: RuntimeStore | null | undefined, + controller: Required>, + args: AdmittedSpawnArgs +): ReturnType { + const outcome = await store?.admitTerminalPane?.(admissionFor(args), hostIdOf(args)) + if (outcome !== undefined && outcome !== 'admitted' && outcome !== 'exists') { + // Only a split is refused: its parent pane left the layout. + throw new Error('terminal_split_source_not_found') + } + const { groupId: _groupId, ...spawnArgs } = args + let result: Awaited> + try { + result = await controller.spawn(spawnArgs) + } catch (error) { + if (outcome === 'admitted') { + await withdrawRuntimePane(store, args) + } + throw error + } + const started = result.agentSessionEnsure?.owner.surface ?? result.stablePaneOwner + const elsewhere = started && (started.tabId !== args.tabId || started.leafId !== args.leafId) + if (outcome === 'admitted' && elsewhere) { + await withdrawRuntimePane(store, args) + } + return result +} diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index 6defe84800bb..114f00699045 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -76,6 +76,20 @@ describe('RuntimeProjectHostSetupController host routing', () => { expect(result.repo.id).toBe(remoteRepo.id) }) + it('adds a new project from a folder on an SSH target, never as a local path', async () => { + const { controller, addRepo, addRemoteRepo } = makeController() + + const repo = await controller.addSshRepo({ connectionId: TARGET_ID, remotePath: REMOTE_PATH }) + + expect(addRemoteRepo).toHaveBeenCalledWith({ + connectionId: TARGET_ID, + remotePath: REMOTE_PATH, + kind: 'git' + }) + expect(addRepo).not.toHaveBeenCalled() + expect(repo.connectionId).toBe(TARGET_ID) + }) + it('decodes a percent-encoded SSH target back to its connection id', async () => { const { controller, addRemoteRepo, projectId } = makeController() diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index c3661f06acd2..6dff6bae9848 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -121,6 +121,19 @@ export class RuntimeProjectHostSetupController { return this.completeSetup(args, repo, !knownRepoIds.has(repo.id)) } + /** Registers an existing folder on one of this host's SSH targets as a new project. */ + addSshRepo(args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'folder' | 'git' + }): Promise { + if (!this.deps.getStore()) { + throw new Error('runtime_unavailable') + } + return this.deps.addRemoteRepo({ ...args, kind: args.kind === 'folder' ? 'folder' : 'git' }) + } + async setupClone(args: ProjectHostSetupCloneArgs): Promise { assertCloneHostIsSupported(args.hostId) const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id)) diff --git a/src/main/runtime/runtime-provisioned-root-adoption.test.ts b/src/main/runtime/runtime-provisioned-root-adoption.test.ts new file mode 100644 index 000000000000..5a6f3261a0e5 --- /dev/null +++ b/src/main/runtime/runtime-provisioned-root-adoption.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { + adoptRuntimeProvisionedRoot, + type RuntimeProvisionedRootPorts +} from './runtime-provisioned-root-adoption' + +const root = '/workspace/repo' +const repo: Repo = { + id: 'repo-vm', + path: root, + displayName: 'repo', + badgeColor: '#000', + addedAt: 1 +} + +function checkout(overrides: Partial = {}): GitWorktreeInfo { + return { + path: root, + head: 'abc123', + branch: 'refs/heads/fix-login', + isBare: false, + isMainWorktree: true, + ...overrides + } +} + +function ports(worktrees: GitWorktreeInfo[], sparse = false) { + const setWorktreeMeta = vi.fn() + const notifyChanged = vi.fn() + const value: RuntimeProvisionedRootPorts = { + store: { + getSettings: () => ({ workspaceDir: '/workspaces', nestWorkspaces: false }), + setWorktreeMeta + }, + resolveRepo: async () => repo, + listWorktrees: async () => worktrees, + isSparseCheckoutEnabled: async () => sparse, + invalidateScan: vi.fn(), + notifyChanged, + showWorktree: async (selector) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption only returns the row it was handed; no Worktree field is read. + ({ id: selector.slice('id:'.length), repoId: repo.id, path: root }) as never + } + return { value, setWorktreeMeta, notifyChanged } +} + +describe('adoptRuntimeProvisionedRoot', () => { + it('adopts the primary checkout as the workspace and records it as provisioned-root', async () => { + const { value, setWorktreeMeta, notifyChanged } = ports([checkout()]) + const result = await adoptRuntimeProvisionedRoot( + 'repo-vm', + { name: 'fix-login', baseBranch: 'main', expectedPath: root, expectedRefHead: 'abc123' }, + value + ) + expect(result.worktree.id).toBe(`repo-vm::${root}`) + expect(setWorktreeMeta).toHaveBeenCalledWith( + `repo-vm::${root}`, + expect.objectContaining({ + ephemeralVmCheckoutMode: 'provisioned-root', + displayName: 'fix-login', + orcaCreationSource: 'runtime', + baseRef: 'main' + }) + ) + expect(setWorktreeMeta.mock.calls[0][1]).not.toHaveProperty('hostId') + expect(notifyChanged).toHaveBeenCalledWith('repo-vm') + }) + + it.each<[string, GitWorktreeInfo[], boolean, string]>([ + ['another branch', [checkout({ branch: 'refs/heads/other' })], false, 'requested branch'], + ['another ref', [checkout({ head: 'def456' })], false, 'requested ref'], + ['a linked worktree', [checkout({ isMainWorktree: false })], false, 'primary checkout'], + ['a sparse checkout', [checkout()], true, 'sparse checkout'] + ])('refuses %s and records nothing', async (_label, worktrees, sparse, message) => { + const { value, setWorktreeMeta } = ports(worktrees, sparse) + await expect( + adoptRuntimeProvisionedRoot( + 'repo-vm', + { name: 'fix-login', expectedPath: root, expectedRefHead: 'abc123' }, + value + ) + ).rejects.toThrow(message) + expect(setWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses a project whose root is not the recipe root', async () => { + const { value } = ports([checkout()]) + await expect( + adoptRuntimeProvisionedRoot('repo-vm', { name: 'fix-login', expectedPath: '/other' }, value) + ).rejects.toThrow('does not match') + }) +}) diff --git a/src/main/runtime/runtime-provisioned-root-adoption.ts b/src/main/runtime/runtime-provisioned-root-adoption.ts new file mode 100644 index 000000000000..3becab13b6a0 --- /dev/null +++ b/src/main/runtime/runtime-provisioned-root-adoption.ts @@ -0,0 +1,77 @@ +/** + * Adopts a recipe-provisioned project root that this host runs, as the desktop does for a direct + * SSH root: the root becomes the main worktree's workspace, and no linked worktree is created. + */ +import type { Repo } from '../../shared/repo-types' +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import { getRepoSshConnectionId } from '../../shared/execution-host' +import { isFolderRepo } from '../../shared/repo-kind' +import { + buildProvisionedRootMeta, + provisionedRootPathsEqual, + verifyProvisionedRootCheckout, + type ProvisionedRootMetaStore, + type ProvisionedRootRequest +} from '../provisioned-root-checkout' +import { gitExecFileAsync } from '../git/runner' +import type { RuntimeStore } from './runtime-store-contract' + +export type RuntimeProvisionedRootRequest = ProvisionedRootRequest & { expectedPath: string } + +export type RuntimeProvisionedRootPorts = { + store: ProvisionedRootMetaStore & Pick + resolveRepo: (selector: string) => Promise + listWorktrees: (repoPath: string) => Promise + isSparseCheckoutEnabled: (checkoutPath: string) => Promise + invalidateScan: (repoId: string) => void + notifyChanged: (repoId: string) => void + showWorktree: (selector: string) => Promise +} + +export async function adoptRuntimeProvisionedRoot( + repoSelector: string, + request: RuntimeProvisionedRootRequest, + ports: RuntimeProvisionedRootPorts +): Promise { + if (request.sparseCheckout) { + throw new Error('Provisioned-root recipes do not support sparse checkout.') + } + const repo = await ports.resolveRepo(repoSelector) + if (isFolderRepo(repo) || getRepoSshConnectionId(repo)) { + throw new Error('Provisioned-root adoption requires a Git checkout on this host.') + } + if (!provisionedRootPathsEqual(repo.path, request.expectedPath)) { + throw new Error('The recipe projectRoot does not match the imported Git checkout root.') + } + const [worktrees, sparseCheckoutEnabled] = await Promise.all([ + ports.listWorktrees(repo.path), + ports.isSparseCheckoutEnabled(repo.path) + ]) + const gitWorktree = verifyProvisionedRootCheckout({ + worktrees, + sparseCheckoutEnabled, + projectRoot: request.expectedPath, + request + }) + const worktreeId = `${repo.id}::${gitWorktree.path}` + ports.store.setWorktreeMeta( + worktreeId, + buildProvisionedRootMeta(ports.store, repo, request, { + branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), + now: Date.now(), + source: 'runtime' + }) + ) + ports.invalidateScan(repo.id) + ports.notifyChanged(repo.id) + return { worktree: await ports.showWorktree(`id:${worktreeId}`) } +} + +export async function readLocalSparseCheckoutEnabled(checkoutPath: string): Promise { + const { stdout } = await gitExecFileAsync( + ['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'], + { cwd: checkoutPath } + ) + return stdout.trim() === 'true' +} diff --git a/src/main/runtime/runtime-repository-command-surface.ts b/src/main/runtime/runtime-repository-command-surface.ts index 8820e4e945bd..78d6a38abaa3 100644 --- a/src/main/runtime/runtime-repository-command-surface.ts +++ b/src/main/runtime/runtime-repository-command-surface.ts @@ -40,6 +40,7 @@ export type RuntimeRepositoryCommandSurface = { createProjectHostSetup: RuntimeProjectHostSetupController['createSetup'] setupProjectExistingFolder: RuntimeProjectHostSetupController['setupExistingFolder'] setupProjectClone: RuntimeProjectHostSetupController['setupClone'] + addSshRepo: RuntimeProjectHostSetupController['addSshRepo'] updateProjectHostSetup: RuntimeProjectHostSetupController['updateSetup'] deleteProjectHostSetup: RuntimeProjectHostSetupController['deleteSetup'] listProjectGroups: RuntimeProjectGroupController['listGroups'] @@ -115,6 +116,7 @@ export function installRuntimeRepositoryCommandSurface( createProjectHostSetup: host.createSetup.bind(host), setupProjectExistingFolder: host.setupExistingFolder.bind(host), setupProjectClone: host.setupClone.bind(host), + addSshRepo: host.addSshRepo.bind(host), updateProjectHostSetup: host.updateSetup.bind(host), deleteProjectHostSetup: host.deleteSetup.bind(host), listProjectGroups: groups.listGroups.bind(groups), diff --git a/src/main/runtime/runtime-repository-ref-queries.test.ts b/src/main/runtime/runtime-repository-ref-queries.test.ts index 79369ec30cb4..9b64b1c3c932 100644 --- a/src/main/runtime/runtime-repository-ref-queries.test.ts +++ b/src/main/runtime/runtime-repository-ref-queries.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' -import { validateGitExecArgs } from '../../relay/git-exec-validator' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { RuntimeRepositoryRefQueries } from './runtime-repository-ref-queries' @@ -17,42 +16,6 @@ const repo: Repo = { } describe('qualified refs in repository searches', () => { - it.each(['feature', 'origin/feature'])( - 'fills legacy pages before the limit for query %s', - async (query) => { - const exec = vi.fn(async (argv: string[]) => { - validateGitExecArgs(argv) - return { - stdout: - argv[0] === 'remote' - ? 'origin\n' - : [ - 'refs/heads/feature/加\0feature/�', - 'refs/remotes/origin/feature/加\0origin/feature/�', - 'refs/remotes/origin/feature/one\0origin/feature/one', - 'refs/remotes/origin/feature/two\0origin/feature/two', - 'refs/remotes/origin/feature/three\0origin/feature/three' - ].join('\n'), - stderr: '' - } - }) - getProvider.mockReturnValue({ exec }) - const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => repo }) - expect(await queries.search('id:remote-repo', query, 2, false)).toEqual({ - refs: ['origin/feature/one', 'origin/feature/two'], - refDetails: [ - { refName: 'origin/feature/one', localBranchName: 'feature/one' }, - { refName: 'origin/feature/two', localBranchName: 'feature/two' } - ], - truncated: true - }) - expect(await queries.search('id:remote-repo', query, 2)).toMatchObject({ - refs: ['refs/heads/feature/加', 'refs/remotes/origin/feature/加'], - truncated: true - }) - expect(exec).toHaveBeenCalledWith(expect.arrayContaining(['--count=12']), '/repo') - } - ) it('keeps folder workspaces outside Git searches', async () => { vi.mocked(getSshGitProvider).mockClear() diff --git a/src/main/runtime/runtime-sort-order-scan-cache.test.ts b/src/main/runtime/runtime-sort-order-scan-cache.test.ts new file mode 100644 index 000000000000..8d8d4ed1ebc4 --- /dev/null +++ b/src/main/runtime/runtime-sort-order-scan-cache.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import './orca-runtime-test-lifecycle.spec' +import { OrcaRuntimeService, listWorktrees } from './orca-runtime-test-mocks.spec' +import type { WorktreeMeta } from './orca-runtime-test-mocks.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + makeWorktreeInfo, + makeWorktreeMeta, + store +} from './orca-runtime-test-fixtures.spec' +import { registerRuntimeWindowLifecycle } from '../window/runtime-window-lifecycle' + +vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } })) + +const firstPath = join(TEST_REPO_PATH, 'first') +const secondPath = join(TEST_REPO_PATH, 'second') +const firstId = `${TEST_REPO_ID}::${firstPath}` +const secondId = `${TEST_REPO_ID}::${secondPath}` + +async function createScannedRuntime(): Promise> { + const metaById: Record = { + [firstId]: makeWorktreeMeta({ sortOrder: 200 }), + [secondId]: makeWorktreeMeta({ sortOrder: 100 }) + } + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Partial) => { + metaById[id] = { ...metaById[id], ...meta } + return metaById[id] + } + }) + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockResolvedValue([ + makeWorktreeInfo(firstPath), + makeWorktreeInfo(secondPath) + ]) + await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + return runtime +} + +async function expectOrderSaveSkipsGitScan( + runtime: InstanceType +): Promise { + const events: { type: string; repoId?: string }[] = [] + const unsubscribe = runtime.onClientEvent((event) => events.push(event)) + + expect(runtime.persistManagedWorktreeSortOrder([secondId, firstId])).toEqual({ updated: 2 }) + unsubscribe() + const listed = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + + expect(events).toEqual([{ type: 'worktreesChanged', repoId: TEST_REPO_ID, sortOrderOnly: true }]) + expect(listWorktrees).toHaveBeenCalledTimes(1) + const sortOrderOf = (id: string) => + listed.worktrees.find((worktree) => worktree.id === id)?.sortOrder ?? Number.NaN + expect(sortOrderOf(secondId)).toBeGreaterThan(sortOrderOf(firstId)) + + runtime.publishWorktreeRemovalChange(TEST_REPO_ID) + await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + expect(listWorktrees).toHaveBeenCalledTimes(2) +} + +describe('runtime sort-order saves', () => { + it('publishes the changed order without rescanning Git on a headless host', async () => { + await expectOrderSaveSkipsGitScan(await createScannedRuntime()) + }) + + it('publishes the changed order without rescanning Git through the desktop window notifier', async () => { + const runtime = await createScannedRuntime() + const send = vi.fn() + const mainWindow = { + id: 1, + isDestroyed: () => false, + on: vi.fn(), + webContents: { isDestroyed: () => false, send, on: vi.fn() } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only reads id, isDestroyed, on and webContents. + registerRuntimeWindowLifecycle(mainWindow as unknown as BrowserWindow, runtime) + + await expectOrderSaveSkipsGitScan(runtime) + expect(send).toHaveBeenCalledWith('worktrees:changed', { repoId: TEST_REPO_ID }) + }) +}) diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 27ea24c367ca..acf457c50138 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -58,6 +58,8 @@ export type RuntimeStore = { abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog'] getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState'] persistPtyBinding?: Store['persistPtyBinding'] + admitTerminalPane?: Store['admitTerminalPane'] + withdrawTerminalPane?: Store['withdrawTerminalPane'] getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index c243d21fe03a..3bee3fc6ed0f 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -67,6 +67,8 @@ export type TerminalCreateOptions = { terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' + /** The tab group a background create joins; defaults to the first group. */ + targetGroupId?: string startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] /** The surface that asked for this `startupAgent` launch; the runtime attributes every one it diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index e2a4cd1df5d0..f95c28a4e122 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -47,8 +47,7 @@ export function buildRuntimeTerminalOrphanSession(args: { customTitle: null, color: null, sortOrder: tabsById.size, - createdAt: Date.now(), - pendingActivationSpawn: true + createdAt: Date.now() } tabsById.set(claim.tabId, tab) } diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index ae7503c712bf..3141cdf813b9 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -2,8 +2,20 @@ import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' /** A main-side create always opens its own tab. */ -export function runtimeNewTabPlacement(): TerminalPanePlacement { - return { kind: 'new-tab' } +export function runtimeNewTabPlacement(viewMode?: 'terminal' | 'chat'): TerminalPanePlacement { + return viewMode ? { kind: 'new-tab', row: { viewMode } } : { kind: 'new-tab' } +} + +/** A runtime-started tab opens in the group asked for, else the first group (as the live view). */ +export function newTabIn(opts: { viewMode?: 'terminal' | 'chat'; targetGroupId?: string }): { + placement: TerminalPanePlacement + groupId?: string +} { + const { targetGroupId } = opts + return { + placement: runtimeNewTabPlacement(opts.viewMode), + ...(targetGroupId ? { groupId: targetGroupId } : {}) + } } export function runtimeSplitPlacement( diff --git a/src/main/runtime/screen-ruled-readiness-scope.test.ts b/src/main/runtime/screen-ruled-readiness-scope.test.ts deleted file mode 100644 index af3fd48ae449..000000000000 --- a/src/main/runtime/screen-ruled-readiness-scope.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { readRuntimeFixture } from './agent-transcript-replay-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Why: the grid-trust check and raw rows belong to screen-ruled agents; every other agent must -// keep reading the screen exactly as it did before them. -describe('screen-rule trust stays with screen-ruled agents', () => { - it('a Codex pane still settles from its screen when the PTY reports another grid', async () => { - const options = { - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex' as const, - // Only the live screen shows this recording's header (STA-8628): the text reads `dirctory:`. - data: readRuntimeFixture('codex-0157-effort-override-embedded-warning'), - size: { cols: 120, rows: 40 } - } - const { runtime, handle } = await createTranscriptPane(options) - await runtime.readTerminal(handle, { screen: true }) - options.size = { cols: 100, rows: 30 } - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 20_000) -}) diff --git a/src/main/runtime/ssh-file-explorer-watch-test-connection.ts b/src/main/runtime/ssh-file-explorer-watch-test-connection.ts deleted file mode 100644 index 9521c175a8cf..000000000000 --- a/src/main/runtime/ssh-file-explorer-watch-test-connection.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' -import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' -import { - encodeJsonRpcFrame, - HEADER_LENGTH, - MessageType, - parseJsonRpcMessage, - type JsonRpcMessage -} from '../ssh/relay-protocol' - -export function createSshFileExplorerWatchTestConnection( - rootPath = '/remote/repo', - connectionId = 'ssh-1' -) { - const written: Buffer[] = [] - let receive: (data: Buffer) => void = () => undefined - let sequence = 1 - const transport: MultiplexerTransport = { - write: (data) => { - written.push(data) - }, - onData: (callback) => { - receive = callback - }, - onClose: () => undefined - } - const mux = new SshChannelMultiplexer(transport) - const provider = new SshFilesystemProvider(connectionId, mux) - const messages = (): JsonRpcMessage[] => - written - .filter((frame) => frame[0] === MessageType.Regular) - .map((frame) => parseJsonRpcMessage(frame.subarray(HEADER_LENGTH))) - const send = (message: JsonRpcMessage): void => { - receive(encodeJsonRpcFrame(message, sequence++, 0)) - } - const watchRequest = () => { - const request = messages().find( - (message) => 'method' in message && message.method === 'fs.watch' - ) - if (!request || !('id' in request) || !('method' in request)) { - throw new Error('No pending fs.watch request') - } - return request - } - return { - mux, - provider, - countRequests: (method: string) => - messages().filter((message) => 'method' in message && message.method === method).length, - settleWatch: () => send({ jsonrpc: '2.0', id: watchRequest().id, result: null }), - emitChange: (absolutePath = `${rootPath}/current.ts`) => - send({ - jsonrpc: '2.0', - method: 'fs.changed', - params: { events: [{ kind: 'update', absolutePath }] } - }), - failWatch: (message: string) => { - const watchId = watchRequest().params?.watchId - if (typeof watchId !== 'number') { - throw new Error('No remote watch id') - } - send({ jsonrpc: '2.0', method: 'fs.watchFailed', params: { rootPath, watchId, message } }) - } - } -} diff --git a/src/main/runtime/stale-title-display-clear.test.ts b/src/main/runtime/stale-title-display-clear.test.ts deleted file mode 100644 index 3a5c169573c1..000000000000 --- a/src/main/runtime/stale-title-display-clear.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { clearWorkingIndicators } from '../../shared/agent-title-status' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Synthetic protocol cases: they test which title readiness, display and presence each read, -// not what a particular CLI paints. The 3 s timer is the stale-working-title clear. -describe('the stale-working title clear is display-only', () => { - it.each([ - ['claude', '. claude', '* claude'], - ['codex', '⠋ Codex working', 'Codex ready'], - ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], - ['opencode', '⠋ OC | task', 'OC | task'], - ['pi', 'π : project', 'π - project'] - ] as const)( - '%s does not become ready when its display title clears', - async (agent, busy, idle) => { - vi.useFakeTimers() - try { - const titleChanges: string[] = [] - const { runtime, handle } = await createTranscriptPane( - { - paneTitle: busy, - foregroundProcess: agent, - data: '', - launchAgent: agent - }, - { - onTerminalSideEffects: (batch) => { - for (const fact of batch.facts) { - if (fact.kind === 'title') { - titleChanges.push(fact.normalizedTitle) - } - } - } - } - ) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) - // A renderer graph publish can carry the display-only cleared title back to main. - runtime.syncWindowGraph(1, { - tabs: [ - { - tabId: 'tab-1', - worktreeId: 'wt-1', - title: clearWorkingIndicators(busy), - activeLeafId: '11111111-1111-4111-8111-111111111111', - layout: null - } - ], - leaves: [ - { - tabId: 'tab-1', - worktreeId: 'wt-1', - leafId: '11111111-1111-4111-8111-111111111111', - paneRuntimeId: 1, - ptyId: TRANSCRIPT_PANE_PTY_ID, - paneTitle: clearWorkingIndicators(busy) - } - ] - }) - const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) - const assertion = expect(waiting).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) - const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) - const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) - await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) - } finally { - vi.useRealTimers() - } - } - ) - - // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. - it.each([['⠋ repo']])( - 'an agent that exited behind %s does not pass for a running agent', - async (title) => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'zsh', - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) - // The agent exits and the shell prints its prompt: output, but no title. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false, - status: null - }) - await expect( - runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) - ).resolves.toBe(false) - } finally { - vi.useRealTimers() - } - } - ) - - it('a live agent behind a cleared title is still found by its process', async () => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: null - }) - await expect( - runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) - ).resolves.toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it("the agent's next genuine title proves presence again", async () => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: null, - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'working' - }) - } finally { - vi.useRealTimers() - } - }) - - it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: null, - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'working' - }) - }) -}) diff --git a/src/main/runtime/terminal-ansi-normalization.ts b/src/main/runtime/terminal-ansi-normalization.ts index e1bbe33a79d9..253f6c2d8f5b 100644 --- a/src/main/runtime/terminal-ansi-normalization.ts +++ b/src/main/runtime/terminal-ansi-normalization.ts @@ -1,5 +1,8 @@ import { MAX_TAIL_PENDING_ANSI_CHARS } from './terminal-tail-limits' -import { classifyTerminalEscapeIntroducer } from '../../shared/terminal-escape-introducer' +import { + classifyTerminalEscapeIntroducer, + type TerminalEscapeIntroducer +} from '../../shared/terminal-escape-introducer' import { ownRetainedString } from '../../shared/own-retained-string' export function parseAnsiControlSequence( @@ -14,44 +17,56 @@ export function parseAnsiControlSequence( | null { // charCodeAt, not value[i]: indexing mints a one-char string on every escape. const introducer = classifyTerminalEscapeIntroducer(value.charCodeAt(escapeIndex + 1)) + const endIndex = findTerminalEscapeSequenceEnd(value, escapeIndex, introducer) + if (endIndex === -1) { + return null + } + if (introducer !== 'csi') { + return { kind: 'other', endIndex } + } + return parseTerminalCsiAtEnd(value, escapeIndex, endIndex) +} + +function parseTerminalCsiAtEnd(value: string, escapeIndex: number, endIndex: number) { + const params = value.slice(escapeIndex + 2, endIndex) + const firstParamMatch = /^(\d+)/.exec(params) + return { + kind: 'csi' as const, + final: value[endIndex] ?? '', + params, + firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, + endIndex + } +} + +/** The index of the sequence's last code unit, or -1 when the input ends first. */ +function findTerminalEscapeSequenceEnd( + value: string, + escapeIndex: number, + introducer: TerminalEscapeIntroducer +): number { if (introducer === 'csi') { for (let index = escapeIndex + 2; index < value.length; index += 1) { const code = value.charCodeAt(index) - if (code < 0x40 || code > 0x7e) { - continue - } - const params = value.slice(escapeIndex + 2, index) - const firstParamMatch = /^(\d+)/.exec(params) - return { - kind: 'csi', - final: value[index] ?? '', - params, - firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, - endIndex: index + if (code >= 0x40 && code <= 0x7e) { + return index } } - return null + return -1 } - if (introducer === 'osc') { + if (introducer === 'osc' || introducer === 'string') { for (let index = escapeIndex + 2; index < value.length; index += 1) { - if (value[index] === '\u0007') { - return { kind: 'other', endIndex: index } + const code = value.charCodeAt(index) + if (code === 0x07 && introducer === 'osc') { + return index } - if (value[index] === '\u001b' && value[index + 1] === '\\') { - return { kind: 'other', endIndex: index + 1 } + if (code === 0x1b && value.charCodeAt(index + 1) === 0x5c) { + return index + 1 } } - return null + return -1 } - if (introducer === 'string') { - for (let index = escapeIndex + 2; index < value.length; index += 1) { - if (value[index] === '\u001b' && value[index + 1] === '\\') { - return { kind: 'other', endIndex: index + 1 } - } - } - return null - } - return { kind: 'other', endIndex: escapeIndex + 1 } + return escapeIndex + 1 } export function hasCanonicalNumericCsiParams(params: string): boolean { @@ -61,18 +76,19 @@ export function hasCanonicalNumericCsiParams(params: string): boolean { export function containsTerminalVerticalLineControl(value: string): boolean { // Only ESC can introduce a vertical control; ordinary output needs no code-unit walk. for (let index = value.indexOf('\x1b'); index !== -1; index = value.indexOf('\x1b', index + 1)) { - const parsed = parseAnsiControlSequence(value, index) - if (!parsed) { + const introducer = classifyTerminalEscapeIntroducer(value.charCodeAt(index + 1)) + const endIndex = findTerminalEscapeSequenceEnd(value, index, introducer) + if (endIndex === -1) { return false } - index = parsed.endIndex if ( - parsed.kind === 'csi' && - parsed.final === 'A' && - hasCanonicalNumericCsiParams(parsed.params) + introducer === 'csi' && + value.charCodeAt(endIndex) === 0x41 && + hasCanonicalNumericCsiParams(value.slice(index + 2, endIndex)) ) { return true } + index = endIndex } return false } @@ -88,42 +104,50 @@ export function normalizeTerminalChunk( const combined = `${pendingAnsi}${chunk}` const parts: string[] = [] let textStart = 0 + // Copy spans and skip parameter parsing for controls the preview discards. for (let index = 0; index < combined.length; index += 1) { - const char = combined[index] - if (char === '\x1b') { + const code = combined.charCodeAt(index) + if (code >= 0x20 && code < 0x7f) { + continue + } + if (code === 0x1b) { appendTerminalNormalizedSpan(parts, combined, textStart, index) if (index + 1 >= combined.length) { return { text: parts.join(''), pendingAnsi: combined.slice(index) } } - const parsed = parseAnsiControlSequence(combined, index) - if (!parsed) { + const introducer = classifyTerminalEscapeIntroducer(combined.charCodeAt(index + 1)) + const endIndex = findTerminalEscapeSequenceEnd(combined, index, introducer) + if (endIndex === -1) { return { text: parts.join(''), // Own the tail so it stops pinning the consumed chunk it was sliced from. pendingAnsi: ownRetainedString(trimPendingAnsiControl(combined.slice(index))) } } - if (parsed.kind === 'csi' && isTerminalPreviewLineControl(parsed)) { - // Why: Codex redraws status text with ANSI controls but no CR; keep them so the tail overwrites the prior frame. - parts.push(combined.slice(index, parsed.endIndex + 1)) + if ( + introducer === 'csi' && + isTerminalPreviewLineControlFinal(combined.charCodeAt(endIndex)) + ) { + const parsed = parseTerminalCsiAtEnd(combined, index, endIndex) + if (isTerminalPreviewLineControl(parsed)) { + // Why: Codex redraws status text with ANSI controls but no CR; keep them so the tail overwrites the prior frame. + parts.push(combined.slice(index, endIndex + 1)) + } } - index = parsed.endIndex + index = endIndex textStart = index + 1 continue } - if (char === '\r' && combined[index + 1] === '\n') { + if (code === 0x0d && combined.charCodeAt(index + 1) === 0x0a) { + // CRLF keeps only its LF, which starts the next span. appendTerminalNormalizedSpan(parts, combined, textStart, index) - parts.push('\n') - index += 1 textStart = index + 1 continue } - const code = combined.charCodeAt(index) if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) { - appendTerminalNormalizedSpan(parts, combined, textStart, index) - parts.push(char) - textStart = index + 1 - } else if (!isTerminalPreviewPrintableCodeUnit(code)) { + continue + } + if (!isTerminalPreviewPrintableCodeUnit(code)) { appendTerminalNormalizedSpan(parts, combined, textStart, index) textStart = index + 1 } @@ -173,23 +197,33 @@ function trimPendingAnsiControl(value: string): string { return `${introducer}${value.slice(-suffixBudget)}` } +/** CSI finals that affect retained preview rows; the sole gate for isTerminalPreviewLineControl. */ +function isTerminalPreviewLineControlFinal(code: number): boolean { + return ( + code === 0x4b || + code === 0x41 || + code === 0x47 || + code === 0x60 || + code === 0x44 || + code === 0x43 + ) +} + function isTerminalPreviewLineControl(parsed: { final: string params: string firstParam: number | null }): boolean { - if (!hasCanonicalNumericCsiParams(parsed.params)) { + // Why: normalizeTerminalChunk pre-filters on this predicate, so any final accepted here must pass it first. + if ( + !isTerminalPreviewLineControlFinal(parsed.final.charCodeAt(0)) || + !hasCanonicalNumericCsiParams(parsed.params) + ) { return false } if (parsed.final === 'K') { const mode = parsed.firstParam ?? 0 return mode === 0 || mode === 1 || mode === 2 } - return ( - parsed.final === 'A' || - parsed.final === 'G' || - parsed.final === '`' || - parsed.final === 'D' || - parsed.final === 'C' - ) + return true } diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 84d7e2042639..47104705d101 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -1,5 +1,3 @@ -// A worker parked on an interactive prompt must be distinguishable from one that is thinking -// or inside a long tool call (STA-4513, STA-3714). import { readFileSync } from 'node:fs' import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { join } from 'node:path' @@ -8,9 +6,11 @@ import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './orca-runtime-core' import { createTranscriptPane, type TranscriptPaneOptions, - TRANSCRIPT_PANE_PTY_ID as PTY_ID + TRANSCRIPT_PANE_PTY_ID as PTY_ID, + waitForTranscriptIdle } from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { readRuntimeFixture } from './agent-transcript-replay-test-harness' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,207 +19,319 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no -// escape bytes and no carriage returns, so they came through a terminal's renderer and a -// clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — -// and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. -// Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. -function fixture(name: string): string { - return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') -} -const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt') -const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call') -const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval') - -// Claude Code 2.1.234's own trust screen, which the runtime already matched by shape. -const CLAUDE_TRUST = [ - 'Accessing workspace:\n', - '/private/tmp/repo\n', - 'Quick safety check: Is this a project you created or one you trust?\n', - '❯ 1. Yes, I trust this folder\n', - ' 2. No, exit\n' -].join('') - -function agentStatusOsc(state: string): string { - return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` -} - -async function createPane( - options: TranscriptPaneOptions -): Promise>> { - // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the - // production status path rather than silently disappearing in a bare runtime fixture. - const statusWiring = makeAgentStatusStoreWiring() - return createTranscriptPane(options, statusWiring.deps) -} - -// cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads -// that as `working`; the title is identical whether it is running a command or waiting. -const CURSOR_TITLE = '⠇ Cursor Agent' - -afterEach(() => vi.useRealTimers()) - -describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { - describe('cursor-agent approval menu, the case no hook reports', () => { - it('names the pending approval on the pane a coordinator inspects', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL +describe('Interactive wait visibility and terminal admission', () => { + // A worker parked on an interactive prompt must be distinguishable from one that is thinking + // or inside a long tool call (STA-4513, STA-3714). + + // cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no + // escape bytes and no carriage returns, so they came through a terminal's renderer and a + // clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — + // and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. + // Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. + function fixture(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') + } + const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt') + const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call') + const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval') + + // Claude Code 2.1.234's own trust screen, which the runtime already matched by shape. + const CLAUDE_TRUST = [ + 'Accessing workspace:\n', + '/private/tmp/repo\n', + 'Quick safety check: Is this a project you created or one you trust?\n', + '❯ 1. Yes, I trust this folder\n', + ' 2. No, exit\n' + ].join('') + + function agentStatusOsc(state: string): string { + return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` + } + + async function createPane( + options: TranscriptPaneOptions + ): Promise>> { + // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the + // production status path rather than silently disappearing in a bare runtime fixture. + const statusWiring = makeAgentStatusStoreWiring() + return createTranscriptPane(options, statusWiring.deps) + } + + // cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads + // that as `working`; the title is identical whether it is running a command or waiting. + const CURSOR_TITLE = '⠇ Cursor Agent' + + afterEach(() => vi.useRealTimers()) + + describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { + describe('cursor-agent approval menu, the case no hook reports', () => { + it('names the pending approval on the pane a coordinator inspects', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' } + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt', + since: expect.any(Number) + }) }) - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' } + it('refuses a guarded send and a dispatch preamble while the approval is up', async () => { + // Why this matters beyond reporting: the coordinator's preamble was typed straight into + // the approval dialog instead of being refused (the STA-2631 shape). Both refusal paths + // are asserted here, not just the `permission` verdict they read. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).rejects.toThrow('terminal_guard_permission') + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'prompt-text', - reason: 'agent-approval-prompt', - since: expect.any(Number) - }) - }) - it('refuses a guarded send and a dispatch preamble while the approval is up', async () => { - // Why this matters beyond reporting: the coordinator's preamble was typed straight into - // the approval dialog instead of being refused (the STA-2631 shape). Both refusal paths - // are asserted here, not just the `permission` verdict they read. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + it('lets a dispatch preamble through once the same lane is working', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).resolves.toBeUndefined() }) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'permission' + it('refuses to call the lane idle while the approval is unanswered', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' }) }) - await expect( - assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) - ).rejects.toThrow('terminal_guard_permission') - await expect( - runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) - ).rejects.toThrow('agent_prompt_blocked') - }) - it('lets a dispatch preamble through once the same lane is working', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_LONG_TOOL_CALL + it('reports no wait for the same agent inside a long tool call', async () => { + // The control: identical vendor, identical spinner title, a real `sleep 60` running. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null }) }) - await expect( - assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) - ).resolves.toBeUndefined() - }) + it('reports no wait once the agent is idle again', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_IDLE + }) - it('refuses to call the lane idle while the approval is unanswered', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) - ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' }) - }) + it('treats an answered menu still in scrollback as scrollback', async () => { + // Why: the menu is not erased on every terminal, so a live dialog is one that still owns + // the bottom of the screen rather than one that merely appears somewhere in the tail. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}` + }) - it('reports no wait for the same agent inside a long tool call', async () => { - // The control: identical vendor, identical spinner title, a real `sleep 60` running. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_LONG_TOOL_CALL + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null }) - }) + it('treats an answered menu followed by any later output as scrollback', async () => { + // Why not keyed on cursor's own follow-up line: output after the menu can be anything, + // and a stale hit here fails tui-idle and refuses prompt injection on a healthy lane. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() + + runtime.onPtyData( + PTY_ID, + '\nCommand completed successfully.\nContinuing automatically.\n', + Date.now() + ) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) - it('reports no wait once the agent is idle again', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_IDLE + it('is not anchored by the agent narrating a choice after the menu', async () => { + // Why: matching each marker independently let prose below the answered menu carry the + // anchor down to the bottom of the screen and revive it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) + it('is not anchored by the agent narrating a choice before the menu', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n` + }) - it('treats an answered menu still in scrollback as scrollback', async () => { - // Why: the menu is not erased on every terminal, so a live dialog is one that still owns - // the bottom of the screen rather than one that merely appears somewhere in the tail. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}` + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) + it('requires the dialog to be the last thing on the screen', async () => { + // Not a tolerance question: one line of slack is exactly enough for the agent's own + // narration to revive an answered menu, and every capture of a live dialog ends on it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n` + }) - it('treats an answered menu followed by any later output as scrollback', async () => { - // Why not keyed on cursor's own follow-up line: output after the menu can be anything, - // and a stale hit here fails tui-idle and refuses prompt injection on a healthy lane. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - runtime.onPtyData( - PTY_ID, - '\nCommand completed successfully.\nContinuing automatically.\n', - Date.now() - ) + it('reports the same wait for a pane whose PTY is not local', async () => { + // The verdict is derived from retained tail and title state, so an SSH or WSL pane + // reaches it the same way a local one does. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL, + connectionId: 'ssh:build-host' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt' + }) + }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + it('ignores a partial menu that names no decision keys', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: 'The agent asked: Run this command? I said yes and it worked.\n' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) }) - it('is not anchored by the agent narrating a choice after the menu', async () => { - // Why: matching each marker independently let prose below the answered menu carry the - // anchor down to the bottom of the screen and revive it. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n` + describe('prompts the runtime already matched but never surfaced', () => { + it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => { + // A pane on its trust screen still wears Orca's tab title; the agent has set none. + const { runtime, handle } = await createPane({ + paneTitle: 'sta4513-claude', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } + }) }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + it('still lets a live working title clear a stale startup prompt', async () => { + // Pins the shared authority getTerminalAgentStatus and the agent-prompt send guard + // already use: for the startup modals, a live non-permission title is the staleness + // proof, because their text survives in scrollback with no self-dismissal marker. + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) }) - it('is not anchored by the agent narrating a choice before the menu', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n` + describe('hook-reported waits (STA-3714)', () => { + it('surfaces an agent-reported blocked state with hook provenance', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'hook', + since: expect.any(Number) + }) + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'hook' } + }) }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) + it('reports no wait for a hook-reported working turn', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('working') + }) - it('requires the dialog to be the last thing on the screen', async () => { - // Not a tolerance question: one line of slack is exactly enough for the agent's own - // narration to revive an answered menu, and every capture of a live dialog ends on it. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n` + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + it('drops a retained permission row once the agent stops owning the pane', async () => { + // Why not the title alone: a shell that takes the pane back usually sets something like + // `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for + // AGENT_STATUS_STALE_AFTER_MS — half an hour of reporting a dead agent as waiting. + const { runtime, handle } = await createPane({ + paneTitle: 'jinwoo@host: ~/repo', + foregroundProcess: 'zsh', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('drops a retained permission row once a shell owns the pane', async () => { + const { runtime, handle } = await createPane({ + paneTitle: 'zsh', + foregroundProcess: 'zsh', + data: agentStatusOsc('blocked') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) }) - it('reports the same wait for a pane whose PTY is not local', async () => { - // The verdict is derived from retained tail and title state, so an SSH or WSL pane - // reaches it the same way a local one does. + it('still reports a live dialog restored from terminal history', async () => { + // Why this case exists: a lane parked on a prompt emits no bytes, so an Orca restart is + // exactly when it would go quiet forever. A restored tail carries no waitBlockedAt, and + // the approval menu does not need one — being at the bottom of the restored screen is + // itself the proof that this is where the pane stopped. const { runtime, handle } = await createPane({ paneTitle: CURSOR_TITLE, foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL, - connectionId: 'ssh:build-host' + data: '' }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CURSOR_APPROVAL, lastTitle: CURSOR_TITLE }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ source: 'prompt-text', @@ -227,232 +339,151 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { }) }) - it('ignores a partial menu that names no decision keys', async () => { + it('does not resurrect a startup modal from a restored tail', async () => { + // The startup prompts keep the timestamp rule: their text lingers in scrollback with no + // marker for whether it was answered, so restored bytes alone must not mint a wait. const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: 'The agent asked: Run this command? I said yes and it worked.\n' + paneTitle: 'sta4513-claude', + foregroundProcess: 'claude', + data: '' }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CLAUDE_TRUST }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - }) - describe('prompts the runtime already matched but never surfaced', () => { - it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => { - // A pane on its trust screen still wears Orca's tab title; the agent has set none. + it('stops reporting a wait once the pane is no longer running', async () => { + // Why: the menu stays at the bottom of a dead pane's tail forever. A worker whose process + // is gone needs intervention, not an answer, so it must not read as blocked on a human. const { runtime, handle } = await createPane({ - paneTitle: 'sta4513-claude', - foregroundProcess: 'claude', - data: CLAUDE_TRUST + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } - }) + runtime.onPtyExit(PTY_ID, 0) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() }) - it('still lets a live working title clear a stale startup prompt', async () => { - // Pins the shared authority getTerminalAgentStatus and the agent-prompt send guard - // already use: for the startup modals, a live non-permission title is the staleness - // proof, because their text survives in scrollback with no self-dismissal marker. + it('does not accrue a probe per poll while the foreground probe wedges', async () => { + // Why: the timeout abandons the wait, not the request. Without single-flighting, a + // coordinator watching a wedged remote host adds one live probe on every poll. + let probes = 0 const { runtime, handle } = await createPane({ paneTitle: '✻ Claude Code', foregroundProcess: 'claude', - data: CLAUDE_TRUST + data: agentStatusOsc('waiting'), + onForegroundProbe: () => { + probes += 1 + }, + foregroundProbeHangs: true }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - }) - - describe('hook-reported waits (STA-3714)', () => { - it('surfaces an agent-reported blocked state with hook provenance', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const probing = Promise.all([ + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle) + ]) + void probing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await probing + + expect(probes).toBe(1) + } finally { + vi.useRealTimers() + } + }, 20_000) + + it('accepts a menu whose keys are rendered as glyphs', async () => { const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting') + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: [ + 'Run this command?\n', + ' → Run (once) (\u21b5)\n', + ' Run Everything (\u21e7\u21b9)\n' + ].join('') }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'hook', - since: expect.any(Number) - }) - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'hook' } + reason: 'agent-approval-prompt' }) }) - it('reports no wait for a hook-reported working turn', async () => { + it('still rejects prose that merely ends in parentheses', async () => { const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('working') + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n` }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - it('drops a retained permission row once the agent stops owning the pane', async () => { - // Why not the title alone: a shell that takes the pane back usually sets something like - // `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for - // AGENT_STATUS_STALE_AFTER_MS — half an hour of reporting a dead agent as waiting. + it('leaves the wait unevaluated when the foreground probe wedges', async () => { + // Why bounded: this probe reaches a PTY controller that can be a remote host. A wedged + // one must leave the wait unknown, not stall every caller of showTerminal. const { runtime, handle } = await createPane({ - paneTitle: 'jinwoo@host: ~/repo', - foregroundProcess: 'zsh', - data: agentStatusOsc('waiting') + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting'), + foregroundProbeHangs: true }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('drops a retained permission row once a shell owns the pane', async () => { - const { runtime, handle } = await createPane({ - paneTitle: 'zsh', - foregroundProcess: 'zsh', - data: agentStatusOsc('blocked') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const waiting = runtime.getTerminalInteractiveWait(handle) + void waiting.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await expect(waiting).resolves.toBeUndefined() + const showing = runtime.showTerminal(handle) + void showing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + const show = await showing + expect('agentWait' in show).toBe(false) + } finally { + vi.useRealTimers() + } + }, 15_000) + + it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { + const { runtime } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - }) - - it('still reports a live dialog restored from terminal history', async () => { - // Why this case exists: a lane parked on a prompt emits no bytes, so an Orca restart is - // exactly when it would go quiet forever. A restored tail carries no waitBlockedAt, and - // the approval menu does not need one — being at the bottom of the restored screen is - // itself the proof that this is where the pane stopped. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: '' - }) - runtime.seedTerminalRestoreTail(PTY_ID, { text: CURSOR_APPROVAL, lastTitle: CURSOR_TITLE }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'prompt-text', - reason: 'agent-approval-prompt' - }) - }) - - it('does not resurrect a startup modal from a restored tail', async () => { - // The startup prompts keep the timestamp rule: their text lingers in scrollback with no - // marker for whether it was answered, so restored bytes alone must not mint a wait. - const { runtime, handle } = await createPane({ - paneTitle: 'sta4513-claude', - foregroundProcess: 'claude', - data: '' - }) - runtime.seedTerminalRestoreTail(PTY_ID, { text: CLAUDE_TRUST }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('stops reporting a wait once the pane is no longer running', async () => { - // Why: the menu stays at the bottom of a dead pane's tail forever. A worker whose process - // is gone needs intervention, not an answer, so it must not read as blocked on a human. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - - runtime.onPtyExit(PTY_ID, 0) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() - }) - - it('does not accrue a probe per poll while the foreground probe wedges', async () => { - // Why: the timeout abandons the wait, not the request. Without single-flighting, a - // coordinator watching a wedged remote host adds one live probe on every poll. - let probes = 0 - const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting'), - onForegroundProbe: () => { - probes += 1 - }, - foregroundProbeHangs: true - }) - - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - try { - const probing = Promise.all([ - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle) - ]) - void probing.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - await probing - - expect(probes).toBe(1) - } finally { - vi.useRealTimers() - } - }, 20_000) - - it('accepts a menu whose keys are rendered as glyphs', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: [ - 'Run this command?\n', - ' → Run (once) (\u21b5)\n', - ' Run Everything (\u21e7\u21b9)\n' - ].join('') - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - reason: 'agent-approval-prompt' - }) - }) - - it('still rejects prose that merely ends in parentheses', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n` + await expect( + runtime.getTerminalInteractiveWait('term_does_not_exist') + ).resolves.toBeUndefined() }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) +}) - it('leaves the wait unevaluated when the foreground probe wedges', async () => { - // Why bounded: this probe reaches a PTY controller that can be a remote host. A wedged - // one must leave the wait unknown, not stall every caller of showTerminal. - const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting'), - foregroundProbeHangs: true - }) - - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - try { - const waiting = runtime.getTerminalInteractiveWait(handle) - void waiting.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - await expect(waiting).resolves.toBeUndefined() - const showing = runtime.showTerminal(handle) - void showing.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - const show = await showing - expect('agentWait' in show).toBe(false) - } finally { - vi.useRealTimers() - } - }, 15_000) - - it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { - const { runtime } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - - await expect(runtime.getTerminalInteractiveWait('term_does_not_exist')).resolves.toBeUndefined() +describe('Screen rule readiness scope', () => { + // Why: the grid-trust check and raw rows belong to screen-ruled agents; every other agent must + // keep reading the screen exactly as it did before them. + describe('screen-rule trust stays with screen-ruled agents', () => { + it('a Codex pane still settles from its screen when the PTY reports another grid', async () => { + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex' as const, + // Only the live screen shows this recording's header (STA-8628): the text reads `dirctory:`. + data: readRuntimeFixture('codex-0157-effort-override-embedded-warning'), + size: { cols: 120, rows: 40 } + } + const { runtime, handle } = await createTranscriptPane(options) + await runtime.readTerminal(handle, { screen: true }) + options.size = { cols: 100, rows: 30 } + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 20_000) }) }) diff --git a/src/main/runtime/terminal-vertical-control-scan.test.ts b/src/main/runtime/terminal-vertical-control-scan.test.ts index b672e28c921e..76d37603991a 100644 --- a/src/main/runtime/terminal-vertical-control-scan.test.ts +++ b/src/main/runtime/terminal-vertical-control-scan.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { containsTerminalVerticalLineControl, normalizeTerminalChunk @@ -6,23 +6,59 @@ import { import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' describe('terminal vertical-control scanning', () => { + it('keeps a line control with long retained CSI parameters', () => { + const input = `\x1b[${'1;'.repeat(2048)}A` + expect(normalizeTerminalChunk(input)).toEqual({ + text: input, + pendingAnsi: '' + }) + }) + + it('retains exactly the preview line-control CSI finals', () => { + const retained: string[] = [] + for (let code = 0x40; code <= 0x7e; code += 1) { + const final = String.fromCharCode(code) + for (const params of ['', '2']) { + const control = `\x1b[${params}${final}` + const { text } = normalizeTerminalChunk(`a${control}b`) + if (text === `a${control}b`) { + retained.push(control) + } else { + expect(text).toBe('ab') + } + } + } + expect(retained).toEqual([ + '\x1b[A', + '\x1b[2A', + '\x1b[C', + '\x1b[2C', + '\x1b[D', + '\x1b[2D', + '\x1b[G', + '\x1b[2G', + '\x1b[K', + '\x1b[2K', + '\x1b[`', + '\x1b[2`' + ]) + }) + + it('preserves printable spans and carried controls across chunk boundaries', () => { + const first = normalizeTerminalChunk('漢\ud800\x1b[31m字\t\r\n\x00a\x1b[2') + expect(first).toEqual({ text: '漢\ud800字\t\na', pendingAnsi: '\x1b[2' }) + expect(normalizeTerminalChunk('K\udfff😀\b\r\x7f\x9fend', first.pendingAnsi)).toEqual({ + text: '\x1b[2K\udfff😀\b\rend', + pendingAnsi: '' + }) + }) + it.each([ ['plain', 'log output '.repeat(8192), false], ['nonvertical CSI', `\x1b[31m${'log output '.repeat(8192)}\x1b[0m`, false], ['vertical CSI', `${'漢字😀 output '.repeat(8192)}\x1b[2A`, true] - ] as const)('bounds code-unit inspections on %s output', (_name, input, expected) => { - const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') - let actual: boolean - let inspections: number - try { - actual = containsTerminalVerticalLineControl(input) - inspections = charCodeAt.mock.calls.length - } finally { - charCodeAt.mockRestore() - } - - expect(actual).toBe(expected) - expect(inspections).toBeLessThan(16) + ] as const)('detects vertical controls in %s output', (_name, input, expected) => { + expect(containsTerminalVerticalLineControl(input)).toBe(expected) }) it.each([ diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts index fd9f35e80654..7a61cd340811 100644 --- a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -1,8 +1,16 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi, afterEach } from 'vitest' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { makePaneKey } from '../../shared/stable-pane-id' import type { TuiAgent } from '../../shared/tui-agent' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' import type { OrcaRuntimeService } from './orca-runtime' vi.mock('electron', () => ({ @@ -12,213 +20,403 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// The harness pane's identity (agent-transcript-pane-test-harness.ts). -const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') -const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') -// A Pi turn in progress: no ready sign the other lanes could settle on. -const BUSY_SCREEN = '⠋ Working...\r\n' -// A Codex turn in progress, which Codex's screen rules read as busy. -const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' -// OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. -const OPENCODE_PERMISSION_DIALOG = [ - '△ Permission required', - '# Shell command', - '$ echo hi', - ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', - '' -].join('\r\n') -// OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. -const OPENCODE_QUESTION = [ - ' ┃ Which color do you prefer?', - ' ┃ 1. Red', - ' ┃ 2. Blue', - ' ┃ 3. Type your own answer', - ' ┃ ↑↓ select enter submit esc dismiss', - '' -].join('\r\n') -// Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. -const WAIT_MS = 150 - -function row(overrides: Partial = {}): AgentStatusIpcPayload { - const now = Date.now() - return { - paneKey: PANE_KEY, - connectionId: null, - state: 'done', - prompt: '', - agentType: 'pi', - receivedAt: now, - stateStartedAt: now, - ...overrides +describe('Hermes captured hook readiness', () => { + const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8') + + /** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the + * same normalizer the hook relay uses — so a regression in the event mapping fails here too, + * not only in the provider's own unit test. */ + const status = (eventName: string, payload: Record = {}): string => { + const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) + expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() + return `\x1b]9999;${JSON.stringify(parsed)}\x07` } -} - -async function waitOutcome(options: { - rows: (handle: string) => AgentStatusIpcPayload[] - launchAgent?: TuiAgent - data?: string - afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown -}): Promise { - let handle = '' - const pane = await createTranscriptPane( - { - paneTitle: 'Terminal', - foregroundProcess: options.launchAgent ?? 'pi', - data: options.data ?? BUSY_SCREEN, - launchAgent: options.launchAgent ?? 'pi' - }, - { getAgentStatusSnapshot: () => options.rows(handle) } - ) - handle = pane.handle - await options.afterCreate?.(pane.runtime, pane.handle) - try { - const result = await pane.runtime.waitForTerminal(pane.handle, { - condition: 'tui-idle', - timeoutMs: WAIT_MS + + const readyPane = async () => + createTranscriptPane({ + paneTitle: 'Hermes Agent', + foregroundProcess: 'hermes', + launchAgent: 'hermes', + size: { cols: 120, rows: 31 }, + data: captured }) - return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' - } catch (error) { - return error instanceof Error ? error.message : String(error) - } -} -afterEach(() => { - vi.restoreAllMocks() -}) + describe('Hermes TUI readiness from a captured PTY', () => { + it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => { + const { runtime, handle } = await readyPane() + // What Orca's own managed plugin sends for `on_session_start`. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) -describe('tui-idle hook lane through the runtime', () => { - it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { - expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') - }) + const read = vi.spyOn(runtime, 'readTerminal') + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + ).resolves.toMatchObject({ satisfied: true }) + // The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it. + expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) + }, 5_000) - it('without a row, the same pane does not settle', async () => { - expect(await waitOutcome({ rows: () => [] })).toBe('timeout') - }) + it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { + const { runtime } = await readyPane() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. + const internals = runtime as unknown as { + leaves: Map + issueHandle: (leaf: unknown) => string + } + const leaf = internals.leaves.values().next().value + expect(leaf).toBeDefined() + expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) - it('joins a row on the terminal handle when its pane key is not the pane', async () => { - expect( - await waitOutcome({ - rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] - }) - ).toBe('ready') - }) + await expect( + runtime.waitForTerminal(internals.issueHandle(leaf), { + condition: 'tui-idle', + timeoutMs: 2_000 + }) + ).resolves.toMatchObject({ satisfied: true }) + }, 5_000) - it('falls back to the other lanes for a row no pane key or handle joins', async () => { - expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') - }) + it('does not settle while a turn the user started is still running', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_llm_call` — the first event that means a turn actually began. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_llm_call', { user_message: 'hi' }), + Date.now() + ) - it('ignores the row the PTY id held before a respawn', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect(await waitOutcome({ rows })).toBe('ready') - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => - runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { - value: 0, - generation: 'reset' - }) - }) - ).toBe('timeout') + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('does not settle on a turn-end done row, only on a session boundary', async () => { + const { runtime, handle } = await readyPane() + // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('post_llm_call', { session_id: 's1' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_approval_request` supersedes the boundary row the session opened with. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_approval_request', { command: 'rm -rf build' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) }) +}) + +describe('Claude indexed task wakeup readiness', () => { + const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + const ready = readFileSync( + join(import.meta.dirname, '__fixtures__/claude-ready-task-wakeup.txt'), + 'utf8' + ) - // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: - // typing `pi` to restart the agent in the same shell must not read the old process's done. - it.each([ - ['a prompt Orca sent', 'next task\r'], - ['the user restarting the agent in the same shell', 'pi\r'] - ])('reads no done from before %s', async (_label, input) => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { - runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + function owedRow(paneKey: string): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey, + state: 'working', + mainAgent: { state: 'done', stateStartedAt: now }, + agentType: 'claude', + prompt: '', + connectionId: null, + launchToken: 'transcript-launch', + claudeTaskWakeupPending: 'notification', + providerSession: { key: 'session_id', id: 'session-a' }, + observation: { + origin: 'hook', + authorityId: 'host-a', + incarnation: 1, + revision: 1, + observedAt: now + }, + receivedAt: now, + stateStartedAt: now } - expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') - expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') - }) + } + + afterEach(() => vi.useRealTimers()) - it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => { - runtime.terminalRunFacts.recordInput( - TRANSCRIPT_PANE_PTY_ID, - 'driving', - 'next\r', - before + 1 - ) - runtime.noteTerminalSpawnCommit({ - id: TRANSCRIPT_PANE_PTY_ID, - agentSessionEnsure: { disposition: 'adopted' } - }) + describe('Claude readiness indexed status reads', () => { + it.each([true, false])( + 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', + async (pending) => { + const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) + const row = owedRow(PANE) + const full = vi.fn(() => [row, ...unrelated]) + const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: 'claude', + data: ready, + launchAgent: 'claude' + }, + { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } + ) + row.receivedAt = Date.now() + full.mockClear() + indexed.mockClear() + try { + const waiting = waitForTranscriptIdle(pane, 2_000) + await (pending + ? expect(waiting).rejects.toThrow(/timeout/) + : expect(waiting).resolves.toMatchObject({ satisfied: true })) + expect(indexed).toHaveBeenCalled() + expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) + expect(full).not.toHaveBeenCalled() + } finally { + pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) } - }) - ).toBe('timeout') + } + ) }) +}) - // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. - it('keeps the row while the agent paints its own shell-integration markers', async () => { - expect( - await waitOutcome({ - rows: () => [row({ receivedAt: Date.now() - 1000 })], - data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` - }) - ).toBe('ready') - }) +describe('Canonical hook status readiness', () => { + // The harness pane's identity (agent-transcript-pane-test-harness.ts). + const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') + // A Pi turn in progress: no ready sign the other lanes could settle on. + const BUSY_SCREEN = '⠋ Working...\r\n' + // A Codex turn in progress, which Codex's screen rules read as busy. + const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' + // OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. + const OPENCODE_PERMISSION_DIALOG = [ + '△ Permission required', + '# Shell command', + '$ echo hi', + ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', + '' + ].join('\r\n') + // OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. + const OPENCODE_QUESTION = [ + ' ┃ Which color do you prefer?', + ' ┃ 1. Red', + ' ┃ 2. Blue', + ' ┃ 3. Type your own answer', + ' ┃ ↑↓ select enter submit esc dismiss', + '' + ].join('\r\n') + // Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. + const WAIT_MS = 150 - // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done - // decides; a working row leaves its title and screen rules to decide. - it('settles Codex on its done, and leaves a working row to its screen rules', async () => { - const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } - expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe('ready') - expect( - await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) - ).toBe('timeout') - }) + function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'pi', + receivedAt: now, + stateStartedAt: now, + ...overrides + } + } - it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { - const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } - expect(await waitOutcome({ ...options, rows: () => [] })).toBe( - 'blocked:agent-interactive-prompt' + async function waitOutcome(options: { + rows: (handle: string) => AgentStatusIpcPayload[] + launchAgent?: TuiAgent + data?: string + afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown + }): Promise { + let handle = '' + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ?? 'pi', + data: options.data ?? BUSY_SCREEN, + launchAgent: options.launchAgent ?? 'pi' + }, + { getAgentStatusSnapshot: () => options.rows(handle) } ) - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] - }) - ).toBe('blocked:agent-interactive-prompt') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + handle = pane.handle + await options.afterCreate?.(pane.runtime, pane.handle) + try { + const result = await pane.runtime.waitForTerminal(pane.handle, { + condition: 'tui-idle', + timeoutMs: WAIT_MS }) - ).toBe('ready') + return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + } + + afterEach(() => { + vi.restoreAllMocks() }) - it('blocks on an open question the hook reports, and settles once it is answered', async () => { - const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } - expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] - }) - ).toBe('blocked:agent-interactive-prompt') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] - }) - ).toBe('ready') + describe('tui-idle hook lane through the runtime', () => { + it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { + expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') + }) + + it('without a row, the same pane does not settle', async () => { + expect(await waitOutcome({ rows: () => [] })).toBe('timeout') + }) + + it('joins a row on the terminal handle when its pane key is not the pane', async () => { + expect( + await waitOutcome({ + rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] + }) + ).toBe('ready') + }) + + it('falls back to the other lanes for a row no pane key or handle joins', async () => { + expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') + }) + + it('ignores the row the PTY id held before a respawn', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect(await waitOutcome({ rows })).toBe('ready') + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => + runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { + value: 0, + generation: 'reset' + }) + }) + ).toBe('timeout') + }) + + // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: + // typing `pi` to restart the agent in the same shell must not read the old process's done. + it.each([ + ['a prompt Orca sent', 'next task\r'], + ['the user restarting the agent in the same shell', 'pi\r'] + ])('reads no done from before %s', async (_label, input) => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { + runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + } + expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') + expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') + }) + + it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => { + runtime.terminalRunFacts.recordInput( + TRANSCRIPT_PANE_PTY_ID, + 'driving', + 'next\r', + before + 1 + ) + runtime.noteTerminalSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + agentSessionEnsure: { disposition: 'adopted' } + }) + } + }) + ).toBe('timeout') + }) + + // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. + it('keeps the row while the agent paints its own shell-integration markers', async () => { + expect( + await waitOutcome({ + rows: () => [row({ receivedAt: Date.now() - 1000 })], + data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` + }) + ).toBe('ready') + }) + + // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done + // decides; a working row leaves its title and screen rules to decide. + it('settles Codex on its done, and leaves a working row to its screen rules', async () => { + const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } + expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe( + 'ready' + ) + expect( + await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) + ).toBe('timeout') + }) + + it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe( + 'blocked:agent-interactive-prompt' + ) + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) + + it('blocks on an open question the hook reports, and settles once it is answered', async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) }) }) diff --git a/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts b/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts deleted file mode 100644 index ab2677a98f48..000000000000 --- a/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's - * bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name, - * the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still - * booting, or into the shell. A launch readiness wait holds that title to a quiet stream. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const BOOT_FRAME = 'Loading…\r\n' - -/** The shell echoes the launch line, then its preexec hook titles the pane with the command. */ -async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') { - return createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: agent, - launchAgent: agent, - data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}` - }) -} - -/** The agent is still painting its boot screen: output keeps arriving each second. */ -async function keepBooting( - runtime: Awaited>['runtime'], - seconds: number -): Promise { - for (let second = 0; second < seconds; second += 1) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now()) - await vi.advanceTimersByTimeAsync(1000) - } -} - -describe('a launch waiting on an agent whose shell titled the pane with its name', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - // Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal. - it.each(['gemini', 'copilot'] as const)( - 'does not treat the shell’s auto-title as readiness while %s is still booting', - async (agent) => { - const { runtime, handle } = await launchedPaneTitledByShell(agent) - const settled = vi.fn() - runtime - .waitForTerminal(handle, { - condition: 'tui-idle', - timeoutMs: 60_000, - launchReadiness: true - }) - .then(settled, settled) - - await keepBooting(runtime, 10) - - expect(settled).not.toHaveBeenCalled() - } - ) - - it('settles once the agent’s stream goes quiet under that title', async () => { - const { runtime, handle } = await launchedPaneTitledByShell('gemini') - const settled = vi.fn() - runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000, launchReadiness: true }) - .then(settled, settled) - - await keepBooting(runtime, 4) - await vi.advanceTimersByTimeAsync(8_000) - - expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) - }) - - it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => { - const { runtime, handle } = await launchedPaneTitledByShell('gemini') - const settled = vi.fn() - runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - .then(settled, settled) - - await keepBooting(runtime, 10) - - expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) - }) -}) diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 562ab23ee3ec..ea7241c68eba 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -5,7 +5,6 @@ import type { OrcaRuntimeService } from './orca-runtime' import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness' import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' import { TERMINAL_LIFECYCLE_METHODS } from './rpc/methods/terminal/terminal-lifecycle-methods' -import { getForegroundProcessName } from '../../relay/pty-shell-utils' // #6011 end-to-end: a REAL pty running a REAL process that emits a REAL name-only // OSC title while streaming must not satisfy `orca terminal wait --for tui-idle`. @@ -41,11 +40,10 @@ async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: numbe }) running.push(child) - // Real foreground read against the real pty: the same helper the relay serves - // `pty.getForegroundProcess` with, so corroboration is host-produced here too. + // Real foreground read against the real pty, so corroboration is host-produced here too. const runtime = makeTuiIdleRuntime({ repoPath: '/tmp/tui-idle-real-pty', - getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null) + getForegroundProcess: async () => child.process || null }) runtime.attachWindow(1) const graph: RuntimeSyncWindowGraph = { diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts deleted file mode 100644 index 558aa3f306e5..000000000000 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { - getSyntheticAgentTerminalTitle, - shouldDriveSyntheticAgentTitleFromHook -} from '../../shared/synthetic-agent-title' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' -import { hasExplicitIdleTitle } from './tui-idle-evidence' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -function readTranscript(): string { - return readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') -} - -describe('ZCode readiness from captured terminal bytes', () => { - it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'worker-zcode', - foregroundProcess: 'zcode', - launchAgent: 'zcode', - data: '\x1b[?1049h╭' - }) - await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ - handle, - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - }) - - it('never emits an OSC title, so no title lane can settle its wait', () => { - const data = readTranscript() - expect(data).toContain(String.fromCharCode(27)) - // Why: this absence is the whole reason ZCode needs a body-evidence readiness lane. - expect(data).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\][0-2];`)) - }) - - it('keeps repainting long after the composer mounts, so a quiet window never settles', () => { - const data = readTranscript() - const composerIndex = data.indexOf('╭') - expect(composerIndex).toBeGreaterThan(-1) - // Why: ~175KB of banner animation after the composer is up. A quiet-render window - // measured in hundreds of ms cannot fire anywhere in that span. - expect(data.length - composerIndex).toBeGreaterThan(100_000) - }) - - it('leaves no durable readiness evidence in the wait-text tail', async () => { - const data = readTranscript() - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'zcode-first-class-workspace', - foregroundProcess: 'zcode', - launchAgent: 'zcode', - data - }) - // Why this asserts a NEGATIVE: Orca's wait text is a line-folded tail, and ZCode paints - // its composer once and then repaints only the banner — so the composer scrolls out and - // no screen rule can settle the wait. This is the evidence for driving ZCode readiness - // from its synthetic hook title instead (see synthetic-agent-title.ts). - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) - ).rejects.toThrow(/timeout/) - }, 15_000) - - it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => { - expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready') - expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required') - expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true) - expect( - hasExplicitIdleTitle({ lastAgentStatus: 'idle', lastOutputAt: Date.now() }, 'ZCode ready') - ).toBe(true) - }) - - it('fires the draft-paste signal at the composer mount, not at the hard timeout', () => { - const data = readTranscript() - const scanner = createDraftPasteReadyScanner('zcode-composer-prompt') - const composerIndex = data.indexOf('╭') - // Why: feeding the stream in PTY-sized chunks proves the marker survives chunk splits. - let readyAt: number | null = null - for (let offset = 0; offset < data.length; offset += 4096) { - const chunk = data.slice(offset, offset + 4096) - if (scanner.observe(chunk).ready) { - readyAt = offset + chunk.length - break - } - } - expect(readyAt).not.toBeNull() - // Ready lands on the chunk that carries the composer corner, not thousands of frames later. - expect(readyAt!).toBeGreaterThanOrEqual(composerIndex) - expect(readyAt!).toBeLessThan(composerIndex + 8192) - }) -}) diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index edc24c7a44a5..b3f0757b762f 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -17,7 +17,6 @@ import { getShellLaunchConfig as getDaemonShellLaunchConfig, getShellReadyWrapperRoot as getDaemonShellReadyWrapperRoot } from './daemon/shell-ready' -import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' import { getShellLaunchConfig as getLocalShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' @@ -142,11 +141,6 @@ describePosix('generated shell wrapper files', () => { await expectWrapperFiles('daemon', getDaemonShellReadyWrapperRoot()) }) - it('relay overlay wrappers', async () => { - ensureOverlayRestoreWrappers(root) - await expectWrapperFiles('relay', root) - }) - // Why a rule and not another fixture: `REPLY`, zsh's shared scratch global, // was the wrapper's resolver out-parameter. A user config that constrained it // (`typeset -r REPLY`) aborted the wrapper at its first executable line — on @@ -161,8 +155,7 @@ describePosix('generated shell wrapper files', () => { getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES) }, (): string => getDaemonShellReadyWrapperRoot() - ], - ['relay', (): void => void ensureOverlayRestoreWrappers(root), (): string => root] + ] ])('%s wrappers write no shell global outside Orca’s namespace', (_transport, generate, dir) => { generate() diff --git a/src/main/source-control/repo-default-branch.test.ts b/src/main/source-control/repo-default-branch.test.ts index a2dd56a8cd03..02ea27b1e94b 100644 --- a/src/main/source-control/repo-default-branch.test.ts +++ b/src/main/source-control/repo-default-branch.test.ts @@ -10,7 +10,8 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0) })) import { diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index d24cde4ea134..8a45ba7f13a9 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -1,6 +1,9 @@ import { resolveDefaultBaseRefViaExec } from '../git/repo' +import { resolveRemoteHeadBranchViaExec, type GitExec } from '../../shared/git-default-base-ref' import { gitExecFileAsync } from '../git/runner' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { hostedReviewRepoScope, scopeGeneration } from './hosted-review-scope-generations' import type { HostedReviewLocalGitOptions } from './hosted-review-git-options' // Why: bounded like TRACKED_UPSTREAM_SNAPSHOT_CACHE in github/client.ts — PR @@ -27,7 +30,13 @@ function getRepoDefaultBranchCacheKey( const runtimeKey = connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` - return [runtimeKey, repoPath].join('\0') + const scope = hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId })) + return [ + runtimeKey, + repoPath, + scopeGeneration(scope), + connectionId ? getSshGitProviderGeneration(connectionId) : 0 + ].join('\0') } function pruneRepoDefaultBranchCache(now: number): void { @@ -45,17 +54,80 @@ function pruneRepoDefaultBranchCache(now: number): void { } } +/** Git over the transport the hosted-review call uses; null when the SSH provider is gone. */ +function createRepoGitExec( + repoPath: string, + connectionId: string | null | undefined, + localGitOptions: HostedReviewLocalGitOptions +): GitExec | null { + const provider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !provider) { + // Why: a dropped SSH provider must not fall back to local git — the + // repoPath is remote, so a local run could answer for the wrong repo. + return null + } + const resolutionDeadline = Date.now() + REPO_DEFAULT_BRANCH_RESOLUTION_BUDGET_MS + // Why: the resolver can try five refs; share one deadline so an unhealthy + // local/WSL/SSH host cannot multiply the refresh delay per fallback probe. + return (argv) => { + const timeoutMs = resolutionDeadline - Date.now() + if (timeoutMs <= 0) { + return Promise.reject(new Error('Default branch resolution timed out.')) + } + return provider + ? provider.exec(argv, repoPath, { timeoutMs }) + : gitExecFileAsync(argv, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier + ? { admissionTier: localGitOptions.admissionTier } + : {}), + timeout: timeoutMs + }) + } +} + /** * Resolve the repository's default branch NAME (`main`, `master`, …) over the * transport the surrounding hosted-review call already uses. Returns null when * unresolvable so callers fail open (#9171 guard skipped, current behavior). */ -export async function getRepoDefaultBranchName( +export function getRepoDefaultBranchName( repoPath: string, connectionId?: string | null, localGitOptions: HostedReviewLocalGitOptions = {} ): Promise { - const cacheKey = getRepoDefaultBranchCacheKey(repoPath, connectionId, localGitOptions) + return resolveCachedBranchName( + getRepoDefaultBranchCacheKey(repoPath, connectionId, localGitOptions), + async () => { + const exec = createRepoGitExec(repoPath, connectionId, localGitOptions) + const baseRef = exec ? await resolveDefaultBaseRefViaExec(exec) : null + // Same base-ref → branch-name normalization as git/repo.ts getRemoteFileUrl. + return baseRef ? baseRef.replace(/^origin\//, '') : null + } + ) +} + +/** Branch the remote's own HEAD names; null when Git has not recorded it. */ +export function getRemoteHeadBranchName( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: HostedReviewLocalGitOptions = {} +): Promise { + return resolveCachedBranchName( + [getRepoDefaultBranchCacheKey(repoPath, connectionId, localGitOptions), remoteName].join('\0'), + async () => { + const exec = createRepoGitExec(repoPath, connectionId, localGitOptions) + return exec ? resolveRemoteHeadBranchViaExec(exec, remoteName) : null + } + ) +} + +async function resolveCachedBranchName( + cacheKey: string, + resolve: () => Promise +): Promise { const now = Date.now() const cached = repoDefaultBranchCache.get(cacheKey) if (cached && cached.expiresAt > now) { @@ -71,33 +143,7 @@ export async function getRepoDefaultBranchName( const resolution = (async (): Promise => { let branchName: string | null = null try { - const provider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !provider) { - // Why: a dropped SSH provider must not fall back to local git — the - // repoPath is remote, so a local run could answer for the wrong repo. - return null - } - const resolutionDeadline = Date.now() + REPO_DEFAULT_BRANCH_RESOLUTION_BUDGET_MS - // Why: the resolver can try five refs; share one deadline so an unhealthy - // local/WSL/SSH host cannot multiply the refresh delay per fallback probe. - const baseRef = await resolveDefaultBaseRefViaExec((argv) => { - const timeoutMs = resolutionDeadline - Date.now() - if (timeoutMs <= 0) { - return Promise.reject(new Error('Default branch resolution timed out.')) - } - return provider - ? provider.exec(argv, repoPath, { timeoutMs }) - : gitExecFileAsync(argv, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - ...(localGitOptions.admissionTier - ? { admissionTier: localGitOptions.admissionTier } - : {}), - timeout: timeoutMs - }) - }) - // Same base-ref → branch-name normalization as git/repo.ts getRemoteFileUrl. - branchName = baseRef ? baseRef.replace(/^origin\//, '') : null + branchName = await resolve() } catch { branchName = null } diff --git a/src/main/source-control/repo-remote-head-branch.test.ts b/src/main/source-control/repo-remote-head-branch.test.ts new file mode 100644 index 000000000000..6c223844235b --- /dev/null +++ b/src/main/source-control/repo-remote-head-branch.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, getSshGitProviderMock, getSshGitProviderGenerationMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getSshGitProviderGenerationMock: vi.fn(() => 0) + }) +) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: getSshGitProviderGenerationMock +})) + +import { bumpScopeGeneration, hostedReviewRepoScope } from './hosted-review-scope-generations' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' + +import { + getRemoteHeadBranchName, + getRepoDefaultBranchName, + __resetRepoDefaultBranchCacheForTests +} from './repo-default-branch' + +function remoteHead(remote: string, branch: string): { stdout: string; stderr: string } { + return { stdout: `refs/remotes/${remote}/HEAD\0refs/remotes/${remote}/${branch}\n`, stderr: '' } +} + +describe('getRemoteHeadBranchName', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + getSshGitProviderMock.mockReset() + getSshGitProviderGenerationMock.mockReset().mockReturnValue(0) + __resetRepoDefaultBranchCacheForTests() + }) + + it('keeps remote HEAD caches separate from each other and the repository default', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => + args.includes('refs/remotes/upstream/HEA[D]') + ? remoteHead('upstream', 'develop') + : remoteHead('origin', 'main') + ) + await expect(getRepoDefaultBranchName('/repo')).resolves.toBe('main') + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('develop') + await expect(getRemoteHeadBranchName('/repo', 'origin')).resolves.toBe('main') + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('develop') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/remotes/upstream/HEA[D]'], + { cwd: '/repo', timeout: expect.any(Number) } + ) + }) + + it('returns null for a missing remote HEAD without resolving origin', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('ignores a symbolic HEAD pointing into another remote', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'refs/remotes/upstream/HEAD\0refs/remotes/origin/main\n', + stderr: '' + }) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + }) + + it('shares concurrent probes only for the same remote', async () => { + let releaseProbe: (() => void) | undefined + const gate = new Promise((resolve) => { + releaseProbe = resolve + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + await gate + return args.includes('refs/remotes/upstream/HEA[D]') + ? remoteHead('upstream', 'develop') + : remoteHead('origin', 'main') + }) + const first = getRemoteHeadBranchName('/repo', 'upstream') + const second = getRemoteHeadBranchName('/repo', 'upstream') + const origin = getRemoteHeadBranchName('/repo', 'origin') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)) + releaseProbe?.() + await expect(Promise.all([first, second, origin])).resolves.toEqual([ + 'develop', + 'develop', + 'main' + ]) + }) + + it('isolates native, WSL and SSH results for the same path and remote', async () => { + gitExecFileAsyncMock.mockImplementation(async (_args, options: { wslDistro?: string }) => + remoteHead('upstream', options.wslDistro ? 'wsl-default' : 'native-default') + ) + const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'ssh-default')) + getSshGitProviderMock.mockReturnValue({ exec }) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('native-default') + await expect( + getRemoteHeadBranchName('/repo', 'upstream', null, { wslDistro: 'Ubuntu' }) + ).resolves.toBe('wsl-default') + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('ssh-default') + expect(exec).toHaveBeenCalledWith( + ['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/remotes/upstream/HEA[D]'], + '/repo', + { timeoutMs: expect.any(Number) } + ) + }) + + it('does not run local Git for a disconnected SSH host', async () => { + getSshGitProviderMock.mockReturnValue(undefined) + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-gone')).resolves.toBeNull() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('caches failed probes briefly, then retries', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('unavailable')) + .mockResolvedValue(remoteHead('upstream', 'develop')) + try { + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + now.mockReturnValue(32_000) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('develop') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + } finally { + now.mockRestore() + } + }) + + it('does not reuse defaults from a replaced SSH provider', async () => { + getSshGitProviderGenerationMock.mockReturnValue(1) + const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'old-default')) + getSshGitProviderMock.mockReturnValue({ exec }) + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('old-default') + getSshGitProviderGenerationMock.mockReturnValue(2) + exec.mockResolvedValue(remoteHead('upstream', 'new-default')) + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('new-default') + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('refreshes defaults after the repository review scope is invalidated', async () => { + gitExecFileAsyncMock.mockResolvedValue(remoteHead('upstream', 'old-default')) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('old-default') + bumpScopeGeneration(hostedReviewRepoScope('/repo', LOCAL_EXECUTION_HOST_ID)) + gitExecFileAsyncMock.mockResolvedValue(remoteHead('upstream', 'new-default')) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('new-default') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ssh/orcad-migration-terminal-gate.test.ts b/src/main/ssh/orcad-migration-terminal-gate.test.ts index 684783552a8e..724f46bb414e 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.test.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.test.ts @@ -67,6 +67,30 @@ describe('migration terminal gate', () => { ).resolves.toMatchObject({ verdict: 'unverifiable' }) }) + it('lets an idle host census prove leases an older build left, with no relay session', async () => { + const leftover = store([ + { ptyId: 'pty-attached', state: 'attached' }, + { ptyId: 'pty-detached', state: 'detached' }, + { ptyId: 'pty-expired', state: 'expired' } + ]) + await expect(assessOrcadMigrationTerminals(leftover, 'ssh-1', null, hostIdle)).resolves.toEqual( + { + verdict: 'exited', + provenPtyIds: ['pty-attached', 'pty-detached', 'pty-expired'] + } + ) + await expect( + assessOrcadMigrationTerminals(leftover, 'ssh-1', null, async () => ({ + verdict: 'live', + count: 2 + })) + ).resolves.toMatchObject({ verdict: 'live', hostTerminals: 2 }) + // Without a census the leases still block: silence is never exit. + await expect(assessOrcadMigrationTerminals(leftover, 'ssh-1', null)).resolves.toMatchObject({ + verdict: 'live' + }) + }) + it('blocks an attached lease as live', async () => { await expect( assessOrcadMigrationTerminals( diff --git a/src/main/ssh/orcad-migration-terminal-gate.ts b/src/main/ssh/orcad-migration-terminal-gate.ts index 575c955d9b57..5b197797d41a 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.ts @@ -92,6 +92,11 @@ async function withoutRelaySession( unresolved: SshRemotePtyLease[], censusHost: CensusHostRelayTerminals | null | undefined ): Promise { + // Why the census decides: with no relay session nothing can answer for these leases, which an + // older build left behind; only a host-wide census can prove their terminals exited. + if (censusHost) { + return await hostWideVerdict(leases, censusHost) + } if (attached.length > 0) { return refuse('live', attached, 'terminals on this host are still running') } diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts index 254056129f61..b8055e5b21a3 100644 --- a/src/main/ssh/orcad-runtime-conversion.test.ts +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -155,7 +155,8 @@ describe('converting an SSH host into a managed server', () => { it('refuses before touching the host while terminals run or cannot be counted', async () => { store.upsertSshRemotePtyLease({ targetId: TARGET.id, ptyId: 'p', state: 'expired' }) - await expect(convert(null)).resolves.toMatchObject({ + const uncounted = async () => ({ verdict: 'unverifiable' as const, count: 0 }) + await expect(convert(null, uncounted)).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts index a0c5618631c5..0247771b0621 100644 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ b/src/main/ssh/orcad-runtime-decommission.ts @@ -55,7 +55,7 @@ export function stopManagedOrcadEnvironment( return refuse( 'live', 'orcad_stop_active_environment', - 'Choose another Active Server in Advanced before stopping this server.' + 'This server is the default host for new projects. Choose another one in Advanced before stopping it.' ) } const stopped = await stopRemote(userDataPath, environment, deployment, args.signal) @@ -89,7 +89,7 @@ export function forgetManagedOrcadEnvironment( return refuse( 'live', 'orcad_forget_active_environment', - 'Choose another Active Server in Advanced before forgetting this server.' + 'This server is the default host for new projects. Choose another one in Advanced before forgetting it.' ) } await unlinkEnvironmentLocally(userDataPath, environment, deployment, policy) diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index deaa320d8b38..3067816ae644 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -1,5 +1,4 @@ -// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: prove the conversion's -// terminal gate against the pinned relay already serving the host, then resolve the context +// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context // (pinned node.exe, host script), deploy and activate, prove readiness and liveness, reach orcad // through the stdio bridge (this account's sshd refuses forwarding), decommission // through the instance-bound stop request, prove exit, and run a GC pass. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -18,7 +17,6 @@ import { installHostileHostAppEnvironment } from './ssh-hostile-host-test-harness' import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { proveWindowsRelayTerminalGate } from './orcad-windows-relay-terminal-gate-test-cell' import { proveWindowsStdioBridge } from './orcad-windows-stdio-bridge-test-cell' import { deployOrcad } from './orcad-remote-deploy' import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' @@ -27,6 +25,8 @@ import { decommissionRemoteOrcad } from './orcad-remote-decommission' import { readOrcadActivationRecord } from './orcad-activation-record-store' import { gcOldOrcadVersions } from './orcad-remote-gc' import { execOrcadRemote } from './orcad-remote-runtime-control' +import { orcadWindowsNodeCommandLine } from './orcad-remote-windows-node' +import { joinRemotePath } from './ssh-remote-platform' import { isWindowsOrcadCellId, readWindowsHostCellDescriptor, @@ -57,7 +57,7 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { async () => { const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') if (!isWindowsOrcadCellId(descriptor.cell)) { - throw new Error(`${descriptor.cell} runs in ssh-relay-windows-host-lane.test.ts`) + throw new Error(`${descriptor.cell} is not a managed orcad cell`) } const sshTarget = windowsHostSshTarget( descriptor, @@ -69,9 +69,6 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { let conn: SshConnection | null = null try { conn = await connectHostileHost(sshTarget) - // A relay-hosted source first: conversion may proceed only once its terminals exited. - Object.assign(receipt, await proveWindowsRelayTerminalGate(conn, sshTarget.id)) - // After the prelude: its relay deploy re-spies `exec`, which is the same spy, and restores it. exec = vi.spyOn(SshConnection.prototype, 'exec') const context = await resolveOrcadRemoteContext(sshTarget, conn) expect(context.host.os).toBe('win32') @@ -97,6 +94,14 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, slotDir)) ) expect(await liveness()).toBe('LIVE') + // Managed terminals' `orca`: the native launcher orcad installed in its profile. + const cli = joinRemotePath(options.host, context.userDataDir, 'cli', 'bin', 'orca.exe') + const status = JSON.parse( + await execOrcadRemote(options, orcadWindowsNodeCommandLine(cli, ['status', '--json'])) + ) + receipt.cliStatus = status.result + expect(status.result.runtime).toMatchObject({ reachable: true }) + expect(status.result.app.desktopWindowStatus).toBe('blocked') // The managed tunnel picks the stdio bridge on this account and reaches orcad through it. receipt.stdioBridge = await proveWindowsStdioBridge(conn, options.host, slotDir) expect(receipt.stdioBridge).toMatchObject({ diff --git a/src/main/ssh/orcad-windows-relay-terminal-gate-test-cell.ts b/src/main/ssh/orcad-windows-relay-terminal-gate-test-cell.ts deleted file mode 100644 index 55507e97aaa7..000000000000 --- a/src/main/ssh/orcad-windows-relay-terminal-gate-test-cell.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * The conversion's terminal gate against a real relay on a Windows SSH host, for the orcad host - * lane: deploy the pinned relay, run one terminal, prove the gate reads it as live, stop it, and - * prove the gate then reads the host as exited. The relay is asked through `pty.listProcesses`, - * exactly as `orcadMigrationRelayPtyLister` asks it through the SSH PTY provider. - */ -import { randomUUID } from 'node:crypto' -import { expect } from 'vitest' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { SshConnection } from './ssh-connection' -import { deployOnce } from './ssh-hostile-host-test-harness' -import { retrySshOwnerRecoveryWhileBlocked } from './ssh-owner-recovery-retry' -import { openSshPtyConsumerSession } from './ssh-pty-consumer-session' -import { assessOrcadMigrationTerminals } from './orcad-migration-terminal-gate' -import { listPreviousRelayPtyIds } from './ssh-legacy-relay-routing' -import { clearPreviousRelayCensus, startPreviousRelayCensus } from './ssh-previous-relay-terminals' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { - censusSshHostRelaysBeforeSession, - hostTerminalProofFromCensus -} from './ssh-host-relay-terminals-on-connect' - -function relayPtyIds(rows: unknown): string[] { - return Array.isArray(rows) - ? rows.flatMap((row: unknown) => - row && typeof row === 'object' && 'id' in row && typeof row.id === 'string' ? [row.id] : [] - ) - : [] -} - -export async function proveWindowsRelayTerminalGate( - conn: SshConnection, - targetId: string -): Promise> { - const relay = await deployOnce(conn) - expect(relay.error, String(relay.error)).toBeNull() - const deployed = relay.deployed! - const mux = new SshChannelMultiplexer(deployed.transport) - try { - await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - clientInstanceId: randomUUID(), - expectedServerBuildId: deployed.serverBuildId - }), - { isCurrent: () => true, onClosed: () => () => {} } - ) - // Earlier relays answer through this deploy's real Windows census, as a connect asks them. - await startPreviousRelayCensus(conn, targetId, deployed) - const lister = Object.assign(async () => relayPtyIds(await mux.request('pty.listProcesses')), { - previous: () => listPreviousRelayPtyIds(targetId) - }) - const noLeases = { getSshRemotePtyLeases: () => [] } - const spawned: unknown = await mux.request('pty.spawn', { cols: 80, rows: 24 }) - const id = relayPtyIds([spawned])[0] ?? '' - expect(id).not.toBe('') - const live = await assessOrcadMigrationTerminals(noLeases, targetId, lister) - expect(live).toMatchObject({ verdict: 'live', ptyIds: [id] }) - await mux.request('pty.shutdown', { id, immediate: true }) - const deadline = Date.now() + 30_000 - while ((await lister()).length > 0 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 500)) - } - // This target's lists are empty now; only the account-wide census may prove the host idle. - const census = async () => - hostTerminalProofFromCensus(await censusSshHostRelaysBeforeSession(conn, targetId)) - expect(await assessOrcadMigrationTerminals(noLeases, targetId, lister)).toMatchObject({ - verdict: 'unverifiable' - }) - const otherDesktop = await liveShellOnAnotherDesktopsRelay(conn, `${targetId}-desktop-b`) - try { - const missed = await assessOrcadMigrationTerminals(noLeases, targetId, lister, census) - expect(missed).toMatchObject({ verdict: 'live' }) - } finally { - await otherDesktop.stop() - } - const exited = await assessOrcadMigrationTerminals(noLeases, targetId, lister, census) - expect(exited).toEqual({ verdict: 'exited', provenPtyIds: [] }) - return { - relayTerminal: id, - gateLive: live.verdict, - gateWithOtherDesktop: 'live', - gateExited: exited.verdict - } - } finally { - clearPreviousRelayCensus(targetId) - mux.dispose() - } -} - -/** Desktop B: a relay for another target id on the same Windows account, running one shell. */ -async function liveShellOnAnotherDesktopsRelay( - conn: SshConnection, - otherTargetId: string -): Promise<{ stop: () => Promise }> { - const deployed = await deployAndLaunchRelay(conn, undefined, 60, otherTargetId) - const mux = new SshChannelMultiplexer(deployed.transport) - await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - clientInstanceId: randomUUID(), - expectedServerBuildId: deployed.serverBuildId - }), - { isCurrent: () => true, onClosed: () => () => {} } - ) - const id = relayPtyIds([await mux.request('pty.spawn', { cols: 80, rows: 24 })])[0] ?? '' - expect(id).not.toBe('') - return { - stop: async () => { - try { - await mux.request('pty.shutdown', { id, immediate: true }) - const deadline = Date.now() + 30_000 - while ( - relayPtyIds(await mux.request('pty.listProcesses')).length > 0 && - Date.now() < deadline - ) { - await new Promise((resolve) => setTimeout(resolve, 500)) - } - } finally { - mux.dispose() - } - } - } -} diff --git a/src/main/ssh/relay-native-dependency-coverage.test.ts b/src/main/ssh/relay-native-dependency-coverage.test.ts deleted file mode 100644 index 45834aeba09e..000000000000 --- a/src/main/ssh/relay-native-dependency-coverage.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { build } from 'esbuild' -import { JSONC_PARSER_ESM_ALIAS } from '../../../config/build-plugins/jsonc-parser-esm' -import { describe, expect, it } from 'vitest' -import { RELAY_NATIVE_DEPS } from './ssh-relay-deploy' - -/** - * The relay is a bundle deployed to a host that has none of Orca's node_modules. - * Every native addon it reaches therefore has to be installed by - * `installNativeDeps`, or the relay silently loses that capability forever. - * - * This exists because #15749 moved the Windows process table onto - * `@vscode/windows-process-tree` without adding it here. The relay tests all - * passed: they inject a fake module through - * `__setWindowsProcessTreeLoaderForTests`, so nothing ever exercised the real - * require that the remote host would fail. Assert against the real graph. - */ - -const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..') - -/** - * Native addons the relay imports but deliberately does NOT install, each with - * the reason its absence is safe. Adding an entry is a decision, not a default: - * anything not listed must appear in RELAY_NATIVE_DEPS. - */ -const DEGRADES_WITHOUT_INSTALL: Record = { - '@vscode/windows-process-tree': - 'Windows-only, and both ways of installing it fail. A normal install rebuilds ' + - 'from source because the tarball carries a binding.gyp, and that build fails ' + - 'with MSB8040 (Spectre-mitigated libraries) even on a host that already has ' + - 'MSVC Build Tools — our patch drops that requirement, and pnpm patches do not ' + - 'cross SSH. Skipping the build keeps the tarball binary, which predates the ' + - 'patch and still caps enumeration at 1024 processes, so a busy host gets a ' + - 'truncated table missing its own pid. windows-process-table.ts falls back to a ' + - 'Get-CimInstance scan when the binding is absent. See ' + - 'docs/reference/windows-process-enumeration.md.' -} - -/** Addons are the packages npm has to build or unpack a binary for. */ -function nativeDependencyNames(): string[] { - const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { - dependencies?: Record - optionalDependencies?: Record - } - const declared = new Set([ - ...Object.keys(pkg.dependencies ?? {}), - ...Object.keys(pkg.optionalDependencies ?? {}) - ]) - return [...declared].filter((name) => { - const dir = join(REPO_ROOT, 'node_modules', name) - return existsSync(join(dir, 'binding.gyp')) || existsSync(join(dir, 'prebuilds')) - }) -} - -/** - * Source files reachable from the relay entry. - * - * Why the metafile and not the emitted bundle: the process table resolves its - * addon through `createRequire`, which esbuild cannot see and minification - * rewrites, so the specifier only survives reliably in the sources. - */ -async function relayReachableSources(): Promise { - const result = await build({ - entryPoints: [join(REPO_ROOT, 'src', 'relay', 'relay.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - write: false, - metafile: true, - external: ['node-pty', '@parcel/watcher', 'electron'], - alias: JSONC_PARSER_ESM_ALIAS, - define: { 'process.env.NODE_ENV': '"production"' } - }) - return Object.keys(result.metafile.inputs).filter((input) => !input.includes('node_modules')) -} - -describe('relay native dependency coverage', () => { - it('installs every native addon the relay bundle imports', async () => { - const sources = await relayReachableSources() - const text = sources.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')).join('\n') - - const imported = nativeDependencyNames().filter( - (name) => text.includes(`'${name}'`) || text.includes(`"${name}"`) - ) - expect(imported.length).toBeGreaterThan(0) - - const uncovered = imported.filter( - (name) => !(name in RELAY_NATIVE_DEPS) && !(name in DEGRADES_WITHOUT_INSTALL) - ) - expect(uncovered).toEqual([]) - }, 60_000) - - it('keeps every installed native dep at the version the app depends on', () => { - const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { - dependencies?: Record - optionalDependencies?: Record - } - const declared = { ...pkg.dependencies, ...pkg.optionalDependencies } - for (const [name, version] of Object.entries(RELAY_NATIVE_DEPS)) { - // The relay pins exact versions where the app carries a range, so compare - // the base: a relay on a different version than the app marshals the same - // node-pty/watcher data through a binding nothing else cross-checks. - expect(declared[name]?.replace(/^[\^~]/, ''), `${name} matches the app`).toBe(version) - } - }) -}) diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts index 1208c470d5d5..525f67492fac 100644 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ b/src/main/ssh/relay-socket-path-limit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-opencode-runtime', () => ({ ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') @@ -69,65 +69,21 @@ vi.mock('./ssh-connection-utils', () => ({ Object.assign(new Error('SSH operation was cancelled'), { name: 'AbortError' }) })) -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import { forceStopRelayForTarget } from './ssh-relay-reset' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' import { parseShortRelaySocketDir, remoteSocketPathFitsLimit, remoteUnixSocketPathByteLimit, - shortRelayVersionSegment, - SHORT_RELAY_SOCKET_DIR_PREFIX + shortRelayVersionSegment } from './relay-socket-path-limit' -import { supersededRelayEndpointListCommand } from './ssh-relay-superseded-endpoints' import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' const LINUX = getRemoteHostPlatform('linux-x64') const DARWIN = getRemoteHostPlatform('darwin-arm64') const WINDOWS = getRemoteHostPlatform('win32-x64') -// The reporter's host: a managed-hosting container whose $HOME is 45 bytes (#10726). -const LONG_HOME = '/var/www/611f7cf9-f715-49e6-91d9-0ffac1d7c4c0' - /** Matches the version this suite's mocked build reports. */ const RELAY_VERSION_DIR_NAME = 'relay-0.1.0+8d4e15ad63eb' -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((event: string, cb: () => void) => { - if (event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -function launchedSockPath(conn: SshConnection): string { - const launch = vi - .mocked(conn.exec) - .mock.calls.map(([command]) => command as string) - .find((command) => command.includes('--detached')) - return /--sock-path\s+'([^']+)'/.exec(launch ?? '')?.[1] ?? '' -} - describe('remote unix socket path limit', () => { it('uses the per-OS sun_path budget and ignores Windows named pipes', () => { expect(remoteUnixSocketPathByteLimit(LINUX)).toBe(107) @@ -167,96 +123,3 @@ describe('remote unix socket path limit', () => { ).toBeNull() }) }) - -describe('relay launch with a long remote $HOME', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - }) - - it('keeps the launched socket path inside the remote sun_path limit', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand) - .mockReset() - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce(LONG_HOME) - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce( - `ORCA-RELAY-SHORT-SOCKET-DIR ${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/${shortRelayVersionSegment(RELAY_VERSION_DIR_NAME)}` - ) - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') - .mockResolvedValue('') - - // A per-target relay instance id is what pushes the default path past the limit: - // 45-byte $HOME + `/.orca-remote/relay-0.1.0+8d4e15ad63eb` + `/relay-.sock` = 110 bytes. - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'ssh-target-1') - - const sockPath = launchedSockPath(conn) - expect(sockPath).not.toBe('') - expect(Buffer.byteLength(sockPath, 'utf8')).toBeLessThanOrEqual( - remoteUnixSocketPathByteLimit(LINUX) as number - ) - expect(sockPath.startsWith(`${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/`)).toBe(true) - expect(result.sockPath).toBe(sockPath) - // The hashed socket name survives intact, so two targets cannot collide -- and the - // build's version segment sits above it, so the next Orca release binds a path of - // its own instead of the one this relay is still holding. - expect(sockPath).toBe( - `${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/${shortRelayVersionSegment(RELAY_VERSION_DIR_NAME)}/${relaySocketNameForInstanceId('ssh-target-1')}` - ) - expect(shortRelayVersionSegment('relay-0.1.0+next')).not.toBe( - shortRelayVersionSegment(RELAY_VERSION_DIR_NAME) - ) - }) - - it('sweeps superseded relays under the short base too, but never the live one', () => { - const currentShortSocketDir = `${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/${shortRelayVersionSegment(RELAY_VERSION_DIR_NAME)}` - const script = supersededRelayEndpointListCommand({ - remoteHome: LONG_HOME, - currentRelayDir: `${LONG_HOME}/.orca-remote/${RELAY_VERSION_DIR_NAME}`, - sockName: relaySocketNameForInstanceId('ssh-target-1'), - currentShortSocketDir - }) - - // A relocated orphan lives outside $HOME, so the sweep that exists to make orphans - // visible has to look at the short base as well. - expect(script).toContain(`short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`) - expect(script).toContain('"$short_base"/relay-*/"$sock_name"') - expect(script).toContain(`short_current='${currentShortSocketDir}'`) - expect(script).toContain('[ -n "$short_current" ] && [ "$dir" = "$short_current" ] && continue') - }) - - it('leaves the socket in the versioned relay dir when it already fits', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand) - .mockReset() - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') - .mockResolvedValue('') - - await deployAndLaunchRelay(conn) - - expect(launchedSockPath(conn)).toBe( - '/home/user/.orca-remote/relay-0.1.0+8d4e15ad63eb/relay.sock' - ) - }) - - it('force-stop also looks for the socket under the short base', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand).mockReset().mockResolvedValue('') - - await forceStopRelayForTarget(conn, 'ssh-1') - - const script = vi.mocked(execCommand).mock.calls[0]?.[1] as string - expect(script).toContain(`short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`) - expect(script).toContain('"$short_base"/relay-*/"$sock_name"') - }) -}) diff --git a/src/main/ssh/remote-install-gc-termination.test.ts b/src/main/ssh/remote-install-gc-termination.test.ts index 7cf6ff3f2082..e53809421563 100644 --- a/src/main/ssh/remote-install-gc-termination.test.ts +++ b/src/main/ssh/remote-install-gc-termination.test.ts @@ -10,7 +10,6 @@ import type { SshConnection } from './ssh-connection' import { gcOldRelayVersions } from './remote-install-gc' import { execCommand } from './ssh-relay-deploy-helpers' import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' import { REMOTE_INSTALL_ORDER_OK } from './remote-install-previous-version' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -118,13 +117,6 @@ const caches = [ collect: () => gcRelayNativeDepsCache(conn, host, home), listing: `ENTRY ${nativeKey}\nENTRY linux-x64-fedcba9876543210\n__ORCA_NATIVE_CACHE__LIST_OK`, references: '__ORCA_NATIVE_CACHE__REFS_OK' - }, - { - name: 'ripgrep', - collect: () => gcRemoteRipgrepCache(conn, host, home), - listing: - 'ENTRY 0123456789abcdef-linux-x64\nENTRY fedcba9876543210-linux-x64\n__ORCA_RG_CACHE__LIST_OK', - references: '__ORCA_RG_CACHE__REFS_OK' } ] diff --git a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts index a0b540777e3b..53a6f9c652ac 100644 --- a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts +++ b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts @@ -1,7 +1,4 @@ -import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' -import type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' /** A bridged SSH CLI whose host the user opted in to control Orca, i.e. the pre-scope behaviour. */ export const CONTROL_GRANTED_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { @@ -16,11 +13,3 @@ export const HOST_BOUND_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { targetId: 'box-1', remoteCliControl: false } - -/** Runs the bridge as a host the user opted in, for tests of the surface such a host reaches. */ -export function runControlGrantedRemoteOrcaCli( - runtime: OrcaRuntimeService, - request: Omit -): Promise { - return runRemoteOrcaCli(runtime, { ...request, callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE }) -} diff --git a/src/main/ssh/ssh-filesystem-stream-retention.test.ts b/src/main/ssh/ssh-filesystem-stream-retention.test.ts deleted file mode 100644 index 15400212f782..000000000000 --- a/src/main/ssh/ssh-filesystem-stream-retention.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { - FileReadCapExceededError, - readFileViaStream, - StreamProtocolError -} from './ssh-filesystem-stream-reader' -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' -import { - encodeJsonRpcFrame, - MessageType, - parseJsonRpcMessage, - type JsonRpcMessage -} from './relay-protocol' - -const muxes: SshChannelMultiplexer[] = [] -afterEach(() => { - for (const mux of muxes.splice(0)) { - mux.dispose() - } -}) - -function createConnection() { - let receive = (_data: Buffer): void => {} - let sequence = 1 - const sent: JsonRpcMessage[] = [] - const mux = new SshChannelMultiplexer({ - write(data) { - if (data[0] === MessageType.Regular) { - sent.push(parseJsonRpcMessage(data.subarray(13))) - } - }, - onData(callback) { - receive = callback - }, - onClose() {} - }) - muxes.push(mux) - return { - mux, - sent, - feed(...messages: JsonRpcMessage[]) { - receive(Buffer.concat(messages.map((message) => encodeJsonRpcFrame(message, sequence++, 0)))) - } - } -} - -async function collect(): Promise { - if (!global.gc) { - throw new Error('Retention test requires --expose-gc') - } - for (let turn = 0; turn < 8; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)) - global.gc() - } -} - -function sendForeignFrames(connection: ReturnType): WeakRef[] { - const refs: WeakRef[] = [] - const stop = connection.mux.onNotificationByMethod('fs.streamChunk', (params) => { - refs.push(new WeakRef(params)) - }) - for (let index = 0; index < 64; index += 1) { - connection.feed({ - jsonrpc: '2.0', - method: 'fs.streamChunk', - params: { streamId: index + 10, seq: 0, data: 'eA==' } - }) - } - stop() - return refs -} - -it('releases foreign stream frames while its own metadata is still pending', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/held.txt') - const refs = sendForeignFrames(connection) - try { - expect(refs).toHaveLength(64) - await collect() - expect(refs.filter((ref) => ref.deref())).toHaveLength(0) - expect(connection.sent).toHaveLength(1) - } finally { - connection.feed({ - jsonrpc: '2.0', - id: 1, - result: { empty: true, totalSize: 0, isBinary: false } - }) - await pending - } -}) - -it('installs metadata before its own chunk and end in the same decoder turn', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/own.txt') - const text = 'same turn 漢\u0000' - const data = Buffer.from(text) - connection.feed( - { - jsonrpc: '2.0', - id: 1, - result: { streamId: 3, totalSize: data.length, isBinary: false, resultEncoding: 'utf-8' } - }, - { - jsonrpc: '2.0', - method: 'fs.streamChunk', - params: { streamId: 3, seq: 0, data: data.toString('base64') } - }, - { jsonrpc: '2.0', method: 'fs.streamEnd', params: { streamId: 3 } } - ) - await expect(pending).resolves.toEqual({ content: text, isBinary: false }) - expect(connection.sent).toContainEqual({ - jsonrpc: '2.0', - method: 'fs.streamAck', - params: { streamId: 3, seq: 0 } - }) -}) - -it('preserves empty image metadata while ignoring earlier unknown stream identifiers', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/empty.png') - connection.feed( - { jsonrpc: '2.0', method: 'fs.streamChunk', params: { streamId: -1, seq: 0, data: 'eA==' } }, - { - jsonrpc: '2.0', - id: 1, - result: { empty: true, totalSize: 0, isBinary: true, isImage: true, mimeType: 'image/png' } - } - ) - await expect(pending).resolves.toEqual({ - content: '', - isBinary: true, - isImage: true, - mimeType: 'image/png' - }) - expect(connection.sent).toHaveLength(1) -}) - -it('rejects metadata missing its stream identifier', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/invalid.txt') - connection.feed({ jsonrpc: '2.0', id: 1, result: { totalSize: 1, isBinary: false } }) - await expect(pending).rejects.toBeInstanceOf(StreamProtocolError) - expect(connection.sent).toHaveLength(1) -}) - -it.each([-1, 51 * 1024 * 1024])( - 'rejects invalid or oversized totalSize %d and cancels the identified stream', - async (totalSize) => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/invalid.png') - connection.feed({ jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize, isBinary: true } }) - await expect(pending).rejects.toBeInstanceOf(FileReadCapExceededError) - expect(connection.sent).toContainEqual({ - jsonrpc: '2.0', - method: 'fs.cancelStream', - params: { streamId: 3 } - }) - } -) - -it('preserves a tighter caller cap before accepting adjacent data', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/small.txt', { maxTextBytes: 1 }) - connection.feed( - { jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize: 2, isBinary: false } }, - { jsonrpc: '2.0', method: 'fs.streamChunk', params: { streamId: 3, seq: 0, data: 'eHg=' } } - ) - await expect(pending).rejects.toBeInstanceOf(FileReadCapExceededError) - expect(connection.sent).toHaveLength(2) -}) - -it('preserves an adjacent own-stream error after metadata', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/removed.txt') - connection.feed( - { jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize: 1, isBinary: false } }, - { - jsonrpc: '2.0', - method: 'fs.streamError', - params: { streamId: 3, code: 'ENOENT', message: 'gone' } - } - ) - await expect(pending).rejects.toMatchObject({ code: 'ENOENT', message: 'gone' }) -}) - -it('preserves the provider fallback when an older relay has no streaming method', async () => { - const connection = createConnection() - const provider = new SshFilesystemProvider('test', connection.mux) - const pending = provider.readFile('/legacy.txt') - try { - connection.feed({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }) - await new Promise((resolve) => setImmediate(resolve)) - expect(connection.sent).toContainEqual({ - jsonrpc: '2.0', - id: 2, - method: 'fs.readFile', - params: { filePath: '/legacy.txt' } - }) - connection.feed({ jsonrpc: '2.0', id: 2, result: { content: 'legacy', isBinary: false } }) - await expect(pending).resolves.toEqual({ content: 'legacy', isBinary: false }) - } finally { - provider.dispose() - } -}) - -// Why: the metadata install moved from the mandatory resolve path to the optional -// beforeResolve hook, and the request timer is cleared before that hook runs. A mux -// that ignores the hook must fail the read, not leave it pending with no deadline. -it('fails the read when a multiplexer resolves without running beforeResolve', async () => { - const connection = createConnection() - vi.spyOn(connection.mux, 'request').mockResolvedValue({ - totalSize: 10, - isBinary: false, - streamId: 7 - }) - - await expect(readFileViaStream(connection.mux, '/no-hook.txt')).rejects.toBeInstanceOf( - StreamProtocolError - ) -}) diff --git a/src/main/ssh/ssh-host-cli-deadline.ts b/src/main/ssh/ssh-host-cli-deadline.ts deleted file mode 100644 index fdf64f6e92c5..000000000000 --- a/src/main/ssh/ssh-host-cli-deadline.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { parseRemoteCliArgs } from './ssh-remote-cli-args' -import { clampOrchestrationAskTimeoutMs } from '../../shared/orchestration-ask-timeout' -import { - isSafeTimerDelayMs, - parsePositiveSafeIntegerNumericText, - parsePositiveSafeIntegerText -} from '../../shared/timer-delay' - -const DEFAULT_KILL_TIMEOUT_MS = 10 * 60_000 -const KILL_TIMEOUT_GRACE_MS = 2 * 60_000 - -/** Kill timer for the host CLI subprocess. Long-poll commands carry their wait - * budget in `--timeout-ms`; extend past it so the CLI's own timeout fires - * first and produces a proper error message. */ -export function resolveHostCliKillTimeoutMs(argv: string[]): number { - const parsed = parseRemoteCliArgs(argv) - const rawTimeout = parsed.flags.get('timeout-ms') - if (parsed.commandPath[0] === 'terminal' && parsed.commandPath[1] === 'send') { - const rawWait = parsed.flags.get('wait-submit') - const seconds = - typeof rawWait === 'string' ? parsePositiveSafeIntegerNumericText(rawWait) : null - if (seconds !== null && seconds <= 3600) { - return Math.max(DEFAULT_KILL_TIMEOUT_MS, seconds * 1000 + KILL_TIMEOUT_GRACE_MS) - } - } - if (parsed.commandPath[0] === 'orchestration' && parsed.commandPath[1] === 'ask') { - const explicit = - typeof rawTimeout === 'string' ? parsePositiveSafeIntegerText(rawTimeout) : null - return Math.max( - DEFAULT_KILL_TIMEOUT_MS, - clampOrchestrationAskTimeoutMs(explicit ?? undefined) + KILL_TIMEOUT_GRACE_MS - ) - } - const explicit = - typeof rawTimeout === 'string' ? parsePositiveSafeIntegerNumericText(rawTimeout) : null - // Why: this feeds the kill timer directly, so a post-grace budget outside the - // timer range degrades to the default instead of throwing at spawn time. - const extended = explicit === null ? null : explicit + KILL_TIMEOUT_GRACE_MS - if (extended !== null && isSafeTimerDelayMs(extended)) { - return Math.max(DEFAULT_KILL_TIMEOUT_MS, extended) - } - return DEFAULT_KILL_TIMEOUT_MS -} diff --git a/src/main/ssh/ssh-host-node-runtime-mode.ts b/src/main/ssh/ssh-host-node-runtime-mode.ts deleted file mode 100644 index bbe7c1e8757b..000000000000 --- a/src/main/ssh/ssh-host-node-runtime-mode.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Hosts whose relay last launched on the opt-in host-Node runtime (`legacy`: host Node plus an - * npm install on the host). That path is outside the supported ladder, so the card says so. - */ -const hostNodeTargets = new Set() - -export function recordSshRelayRuntimeStep(targetId: string, hostNode: boolean): void { - if (hostNode) { - hostNodeTargets.add(targetId) - } else { - hostNodeTargets.delete(targetId) - } -} - -export function isSshRelayOnHostNodeRuntime(targetId: string): boolean { - return hostNodeTargets.has(targetId) -} diff --git a/src/main/ssh/ssh-host-provider-set.test.ts b/src/main/ssh/ssh-host-provider-set.test.ts deleted file mode 100644 index 586537d511b6..000000000000 --- a/src/main/ssh/ssh-host-provider-set.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getSshPtyProvider } from '../ipc/pty' -import { - requireGitProviderForHost, - resolveFilesystemRouteForHost, - resolveGitRouteForHost -} from '../providers/execution-host-provider-dispatch' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - getSshGitProvider, - getSshGitProviderGeneration, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-git-dispatch' -import { registerSshHostProviders, retireSshHostProviders } from './ssh-host-provider-set' -import { clearSshPlainSshMode, setSshPlainSshMode } from './ssh-plain-ssh-mode' - -const targetId = 'host-provider-set-target' - -type ProviderSet = Parameters[1] - -function providerSet(withGit = true) { - const disposePty = vi.fn() - const disposeFs = vi.fn() - const stub = { - pty: { dispose: disposePty }, - fs: { dispose: disposeFs }, - git: withGit ? {} : null - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the set module only stores these stubs and calls `dispose`. - const set = stub as unknown as ProviderSet - return { set, disposePty, disposeFs } -} - -describe('SSH host provider set', () => { - afterEach(() => { - retireSshHostProviders(targetId) - clearSshPlainSshMode(targetId) - }) - - it('registers and retires every provider for the host together', () => { - const { set, disposePty, disposeFs } = providerSet() - registerSshHostProviders(targetId, set) - expect(getSshPtyProvider(targetId)).toBe(set.pty) - expect(getSshFilesystemProvider(targetId)).toBe(set.fs) - expect(getSshGitProvider(targetId)).toBe(set.git) - - retireSshHostProviders(targetId) - expect(disposePty).toHaveBeenCalledOnce() - expect(disposeFs).toHaveBeenCalledOnce() - expect(getSshPtyProvider(targetId)).toBeUndefined() - expect(getSshFilesystemProvider(targetId)).toBeUndefined() - expect(getSshGitProvider(targetId)).toBeUndefined() - }) - - it('a set without git clears an earlier git provider and reports git as unsupported', () => { - registerSshHostProviders(targetId, providerSet().set) - const generationBefore = getSshGitProviderGeneration(targetId) - setSshPlainSshMode(targetId, { reason: 'no_runtime', message: 'm' }) - registerSshHostProviders(targetId, providerSet(false).set) - - expect(getSshGitProvider(targetId)).toBeUndefined() - expect(getSshGitProviderGeneration(targetId)).toBeGreaterThan(generationBefore) - expect(resolveFilesystemRouteForHost(`ssh:${targetId}`)).toMatchObject({ kind: 'ssh' }) - expect(resolveGitRouteForHost(`ssh:${targetId}`)).toMatchObject({ provider: null }) - expect(() => requireGitProviderForHost(`ssh:${targetId}`)).toThrow( - 'Git needs the Orca remote server' - ) - clearSshPlainSshMode(targetId) - expect(() => requireGitProviderForHost(`ssh:${targetId}`)).toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - ) - }) - - it('an owner whose set was replaced disposes only its own providers and leaves the host', () => { - const stale = providerSet(false) - const { set: current, disposePty: disposeCurrentPty } = providerSet() - registerSshHostProviders(targetId, stale.set) - registerSshHostProviders(targetId, current) - - retireSshHostProviders(targetId, stale.set) - - expect(stale.disposePty).toHaveBeenCalledOnce() - expect(stale.disposeFs).toHaveBeenCalledOnce() - expect(disposeCurrentPty).not.toHaveBeenCalled() - expect(getSshPtyProvider(targetId)).toBe(current.pty) - expect(getSshFilesystemProvider(targetId)).toBe(current.fs) - expect(getSshGitProvider(targetId)).toBe(current.git) - }) -}) diff --git a/src/main/ssh/ssh-host-provider-set.ts b/src/main/ssh/ssh-host-provider-set.ts deleted file mode 100644 index c4727efb03e4..000000000000 --- a/src/main/ssh/ssh-host-provider-set.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * The git, filesystem and terminal providers for one SSH host, registered and removed as a unit - * so the host's routes can never disagree about whether it is connected. - */ -import { getSshPtyProvider, registerSshPtyProvider, unregisterSshPtyProvider } from '../ipc/pty' -import { - getSshFilesystemProvider, - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -import type { SshGitProvider } from '../providers/ssh-git-provider' -import type { IFilesystemProvider, IPtyProvider } from '../providers/types' - -export type SshHostProviderSet = { - pty: IPtyProvider - fs: IFilesystemProvider - /** `null`: no Orca runtime on the host (plain SSH), so git is unsupported, not unreachable. */ - git: SshGitProvider | null -} - -/** Replaces every provider for the host; a set without git also clears any earlier git provider. */ -export function registerSshHostProviders(targetId: string, providers: SshHostProviderSet): void { - registerSshPtyProvider(targetId, providers.pty) - registerSshFilesystemProvider(targetId, providers.fs) - if (providers.git) { - registerSshGitProvider(targetId, providers.git) - } else { - unregisterSshGitProvider(targetId) - } -} - -/** - * Disposes and removes the host's providers. With `owned`, the caller's own providers are - * disposed, and the host is cleared only if that set is still the registered one. - */ -export function retireSshHostProviders( - targetId: string, - owned?: Pick -): void { - for (const provider of [ - owned?.pty ?? getSshPtyProvider(targetId), - owned?.fs ?? getSshFilesystemProvider(targetId) - ]) { - if (provider && 'dispose' in provider && typeof provider.dispose === 'function') { - provider.dispose() - } - } - if (owned && getSshPtyProvider(targetId) !== owned.pty) { - return - } - unregisterSshPtyProvider(targetId) - unregisterSshFilesystemProvider(targetId) - unregisterSshGitProvider(targetId) -} diff --git a/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts index 4b8743b97a1d..36fd855e74a5 100644 --- a/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts +++ b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts @@ -55,13 +55,14 @@ describe('the connect-time relay terminal verdict', () => { await expect(decide(census).verdict).resolves.toMatchObject({ verdict: 'unverifiable' }) }) - it('keeps a lease-backed verdict without asking the host', async () => { + // With no relay session, a lease an older build left proves nothing; the host census decides. + it('lets an idle host census prove a lease an older build left', async () => { const { censusHost, verdict } = decide( { verdict: 'none', count: 0 }, { leases: [{ ptyId: 'a', state: 'attached' }] } ) - await expect(verdict).resolves.toEqual({ verdict: 'live', count: 1 }) - expect(censusHost).not.toHaveBeenCalled() + await expect(verdict).resolves.toEqual({ verdict: 'exited', count: 0 }) + expect(censusHost).toHaveBeenCalledTimes(1) }) // Astra pass 4 §2: this target's empty lists cannot see another desktop's relay on the account. @@ -74,17 +75,12 @@ describe('the connect-time relay terminal verdict', () => { expect(censusHost).toHaveBeenCalledTimes(1) }) - it('attributes terminals this target leases or lists to this desktop', async () => { + it('attributes terminals this target lists to this desktop', async () => { const listed = decide( { verdict: 'live', count: 3 }, { lister: Object.assign(async () => ['pty-1'], { previous: async () => [] }) } ) await expect(listed.verdict).resolves.toEqual({ verdict: 'live', count: 1 }) - const leased = decide( - { verdict: 'live', count: 3 }, - { leases: [{ ptyId: 'a', state: 'attached' }] } - ) - await expect(leased.verdict).resolves.not.toHaveProperty('elsewhere') }) // A session whose relays could not answer proves nothing, and no lease here changes that. diff --git a/src/main/ssh/ssh-host-server-move-offer.test.ts b/src/main/ssh/ssh-host-server-move-offer.test.ts deleted file mode 100644 index c39a762a59d2..000000000000 --- a/src/main/ssh/ssh-host-server-move-offer.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - canOfferManagedServerMove, - relayServerStatus, - shouldToastManagedServerMove -} from './ssh-host-server-move-offer' - -const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 3 } - -describe('offering to move a host whose relay terminals keep it on the relay', () => { - it('offers only when live terminals are the reason', () => { - expect(canOfferManagedServerMove(live)).toBe(true) - expect(canOfferManagedServerMove({ reason: 'relay_terminals_unverifiable' })).toBe(false) - expect(canOfferManagedServerMove({ reason: 'refused' })).toBe(false) - expect(canOfferManagedServerMove({ reason: 'orcad_unavailable' })).toBe(false) - }) - - it('toasts once per host per app version', () => { - expect(shouldToastManagedServerMove({}, live, '1.5.0')).toBe(true) - expect( - shouldToastManagedServerMove( - { managedServerMoveOffered: { appVersion: '1.5.0' } }, - live, - '1.5.0' - ) - ).toBe(false) - expect( - shouldToastManagedServerMove( - { managedServerMoveOffered: { appVersion: '1.4.0' } }, - live, - '1.5.0' - ) - ).toBe(true) - expect( - shouldToastManagedServerMove({}, { reason: 'relay_terminals_unverifiable' }, '1.5.0') - ).toBe(false) - }) - - it('carries the offer and terminal count on the published relay status', () => { - expect(relayServerStatus(live, true)).toEqual({ - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 3, - offerMove: true - }) - expect(relayServerStatus(live, false)).not.toHaveProperty('offerMove') - expect( - relayServerStatus( - { route: 'relay', reason: 'refused', detail: 'An automation runs here.' }, - false - ) - ).toEqual({ kind: 'relay', reason: 'refused', detail: 'An automation runs here.' }) - }) - - it('never offers to move terminals another Orca desktop or session runs', () => { - const elsewhere = { ...live, terminals: 1, terminalsElsewhere: true } - expect(canOfferManagedServerMove(elsewhere)).toBe(false) - expect(shouldToastManagedServerMove({}, elsewhere, '1.5.0')).toBe(false) - expect(relayServerStatus(elsewhere, false)).toEqual({ - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 1, - terminalsElsewhere: true - }) - }) -}) diff --git a/src/main/ssh/ssh-host-server-move-offer.ts b/src/main/ssh/ssh-host-server-move-offer.ts deleted file mode 100644 index 386e7ba0c0c7..000000000000 --- a/src/main/ssh/ssh-host-server-move-offer.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** When a connect that kept the relay offers to move the host now, restarting its terminals. */ -import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' -import type { HostServerOnConnectResult } from './ssh-host-server-on-connect' - -type RelayDecision = Extract - -/** - * Only this desktop's live terminals: an unverifiable census, or terminals another desktop or - * session runs, can't be ended by stopping this target's terminals. - */ -export function canOfferManagedServerMove( - decision: Pick -): boolean { - return decision.reason === 'relay_terminals_live' && !decision.terminalsElsewhere -} - -/** The toast shows once per host per app version; the SSH Hosts action stays regardless. */ -export function shouldToastManagedServerMove( - target: Pick, - decision: Pick, - appVersion: string -): boolean { - return ( - canOfferManagedServerMove(decision) && - target.managedServerMoveOffered?.appVersion !== appVersion - ) -} - -export function relayServerStatus( - decision: RelayDecision, - offerMove: boolean -): Extract { - return { - kind: 'relay', - reason: decision.reason, - ...(decision.detail ? { detail: decision.detail } : {}), - ...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}), - ...(offerMove ? { offerMove: true } : {}), - ...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {}) - } -} diff --git a/src/main/ssh/ssh-host-unserved-status.ts b/src/main/ssh/ssh-host-unserved-status.ts new file mode 100644 index 000000000000..feada869c7ee --- /dev/null +++ b/src/main/ssh/ssh-host-unserved-status.ts @@ -0,0 +1,36 @@ +/** What a connect publishes when the managed server doesn't serve the host; there is no fallback. */ +import type { SshManagedServerStatus } from '../../shared/ssh-types' +import type { HostServerOnConnectResult } from './ssh-host-server-on-connect' + +type UnservedDecision = Extract + +// Why `kind: 'relay'`: the published status keeps its shipped shape; it now means "not served". +export function unservedServerStatus( + decision: UnservedDecision +): Extract { + return { + kind: 'relay', + reason: decision.reason, + ...(decision.detail ? { detail: decision.detail } : {}), + ...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}), + ...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {}) + } +} + +/** Untranslated twin of the renderer's status line, for logs and callers without the catalog. */ +export function unservedHostServerMessage(decision: UnservedDecision): string { + switch (decision.reason) { + case 'orcad_unavailable': + return 'This SSH host isn’t supported by this version of Orca. To keep using it, install an older version of Orca.' + case 'relay_terminals_live': + case 'relay_terminals_unverifiable': + return 'Terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.' + case 'refused': + return decision.detail ?? 'Orca couldn’t set up its server on this host.' + case 'source_changed': + return 'An older version of Orca changed this host’s projects. Move those changes to its managed server in SSH Hosts settings to continue.' + case 'deferred': + case 'failed': + return 'Orca couldn’t start its server on this host. Try connecting again.' + } +} diff --git a/src/main/ssh/ssh-hostile-host-cells.ts b/src/main/ssh/ssh-hostile-host-cells.ts index 414ed6a4876a..94874f7fcdc1 100644 --- a/src/main/ssh/ssh-hostile-host-cells.ts +++ b/src/main/ssh/ssh-hostile-host-cells.ts @@ -1,7 +1,7 @@ /** * The design D5/D6 hostile-host matrix: each cell is an SSH target (a container, or the runner's * own loopback sshd) and the place the relay runtime ladder must land there. - * `ssh-relay-hostile-hosts.docker.test.ts` drives the real client-side deploy against each one; + * `ssh-hostile-hosts.docker.test.ts` drives the real client-side deploy against each one; * `.github/workflows/ssh-hostile-hosts.yml` runs it. */ import type { NodeRuntimeTarget, ServerTarget } from '../../shared/node-runtime-pin' diff --git a/src/main/ssh/ssh-hostile-host-managed-orcad.ts b/src/main/ssh/ssh-hostile-host-managed-orcad.ts index 84960124a784..f4ed9de73ebe 100644 --- a/src/main/ssh/ssh-hostile-host-managed-orcad.ts +++ b/src/main/ssh/ssh-hostile-host-managed-orcad.ts @@ -1,8 +1,8 @@ /** * The managed-orcad half of a hostile-host cell: on a fresh host, resolve the context, deploy * orcad, and prove it picked the cell's runtime and uploaded no other. An activated server must - * be live; a refused one must be classified as unable to run, so the relay that follows runs the - * pinned ladder and lands on the cell's rung. + * be live; a refused one must be classified as unable to run, which the connect reports as an + * unsupported host. */ import { posix } from 'node:path' import { expect } from 'vitest' @@ -20,7 +20,7 @@ import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-la import { execOrcadRemote } from './orcad-remote-runtime-control' import { decommissionRemoteOrcad } from './orcad-remote-decommission' import type { HostileHostCellCore, ManagedOrcadExpectation } from './ssh-hostile-host-cells' -import { connectHostileHost, deployOnce } from './ssh-hostile-host-test-harness' +import { connectHostileHost } from './ssh-hostile-host-test-harness' import { hostExecStatus, type HostileHostTarget } from './ssh-hostile-host-test-fixture' const EMPTY_CENSUS = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } @@ -53,7 +53,6 @@ export async function proveManagedOrcadCell( code: expected.code }) expect(classifyOrcadHostUnavailable(deployed)).toBe('native_preflight') - await assertRelayFallback(sshTarget, expected.relayRung) return } expect(deployed, JSON.stringify(deployed)).toMatchObject({ outcome: 'installed-and-activated' }) @@ -82,22 +81,6 @@ async function assertOnlyRuntimeUploaded( } } -/** The connect's next step: no runtime setting, but orcad recorded unable to run on this host. */ -async function assertRelayFallback(sshTarget: SshTarget, rung: 'A' | 'B'): Promise { - const { remoteRuntime: _setting, ...withoutSetting } = sshTarget - const conn = await connectHostileHost({ - ...withoutSetting, - managedServerUnavailable: { reason: 'native_preflight', appVersion: 'hostile-hosts' } - }) - try { - const attempt = await deployOnce(conn) - expect(attempt.error).toBeNull() - expect(attempt.settledRung).toBe(rung) - } finally { - await conn.disconnect().catch(() => {}) - } -} - async function assertLiveThenDecommissioned( slot: OrcadSlotOptions, slotDir: string, diff --git a/src/main/ssh/ssh-hostile-host-test-harness.ts b/src/main/ssh/ssh-hostile-host-test-harness.ts index c5733c98ec17..c301594c9867 100644 --- a/src/main/ssh/ssh-hostile-host-test-harness.ts +++ b/src/main/ssh/ssh-hostile-host-test-harness.ts @@ -3,55 +3,14 @@ * SshConnection, observed through the ladder's own calls and a {@link HostileHostObserver}. * The Docker matrix and the Windows SSH-host lanes both run cells through it. */ -import { randomUUID } from 'node:crypto' import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, posix } from 'node:path' -import { expect, vi } from 'vitest' +import { join } from 'node:path' import { setAppEnvironment } from '../../shared/app-environment' -import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { orcadNodeRuntimeExecutable } from '../../shared/orcad-artifacts' -import type { SshRemoteRuntimeRung, SshTarget } from '../../shared/ssh-types' -import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { SshTarget } from '../../shared/ssh-types' import { SshConnection } from './ssh-connection' -import { - hostileHostCellViolations, - parseForbiddenToolLog, - type HostileHostCellCore, - type RungRefusal -} from './ssh-hostile-host-cells' -import type { HostileHostObserver } from './ssh-hostile-host-observer' -import { retrySshOwnerRecoveryWhileBlocked } from './ssh-owner-recovery-retry' -import { openSshPtyConsumerSession } from './ssh-pty-consumer-session' -import { deployAndLaunchRelay, type RelayDeployResult } from './ssh-relay-deploy' -import { pinnedRelayAddonFiles, pinnedRelayNodePath } from './ssh-relay-pinned-node' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError -} from './ssh-relay-runtime-resolution' -export type DeployAttempt = { - deployed: RelayDeployResult | null - error: unknown - refusals: RungRefusal[] - settledRung: SshRemoteRuntimeRung | null - run: RelayRuntimeLadderRun | null - /** Every command this deploy sent the host, as the SSH layer received it. */ - commands: string[] -} - -/** Input the shell must evaluate to print `expect`; the echoed keystrokes never contain it. */ -export type TerminalProbe = { input: string; expect: string } - -export const TERMINAL_PROBES = { - posix: { input: 'echo ORCA_HOSTILE_$((6*7))\r', expect: 'ORCA_HOSTILE_42' }, - // Why through cmd.exe: the same line evaluates whether the relay's PTY shell is cmd or PowerShell. - windows: { input: 'cmd /d /c set /a 6*7+100000\r', expect: '100042' } -} as const satisfies Record - -/** Points the app environment at a throwaway userData; returns its cleanup. */ export function installHostileHostAppEnvironment(): () => void { const userData = mkdtempSync(join(tmpdir(), 'orca-hostile-hosts-userdata-')) const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) @@ -72,232 +31,3 @@ export async function connectHostileHost(sshTarget: SshTarget): Promise { - const refused = vi.spyOn(RelayRuntimeLadderRun.prototype, 'refused') - const settle = vi.spyOn(RelayRuntimeLadderRun.prototype, 'settle') - const exec = vi.spyOn(SshConnection.prototype, 'exec') - const attempt: DeployAttempt = { - deployed: null, - error: null, - refusals: [], - settledRung: null, - run: null, - commands: [] - } - try { - attempt.deployed = await deployAndLaunchRelay(conn, undefined, 60) - } catch (error) { - attempt.error = error - } finally { - attempt.refusals = refused.mock.calls.map(([step, reason]) => ({ step, reason })) - attempt.settledRung = settle.mock.calls.at(-1)?.[0] ?? null - const context: unknown = settle.mock.contexts.at(-1) - attempt.run = context instanceof RelayRuntimeLadderRun ? context : null - attempt.commands = exec.mock.calls.map(([command]) => command) - refused.mockRestore() - settle.mockRestore() - exec.mockRestore() - } - return attempt -} - -export function deployErrorText(attempt: DeployAttempt): string | null { - const { error } = attempt - return error ? (error instanceof Error ? error.message : String(error)) : null -} - -export async function assertCell( - cell: HostileHostCellCore, - observer: HostileHostObserver, - attempt: DeployAttempt -): Promise { - const { error } = attempt - const violations = hostileHostCellViolations(cell, { - settledRung: attempt.settledRung, - target: attempt.run?.facts?.target ?? null, - unavailableReason: error instanceof RemoteRuntimeUnavailableError ? error.reason : null, - deployError: deployErrorText(attempt), - refusals: attempt.refusals, - forbiddenToolCalls: parseForbiddenToolLog(await observer.readForbiddenToolLog()) - }) - expect(violations, `${cell.id}: ${violations.join('; ')}`).toEqual([]) -} - -/** Resends the probe this often until the shell evaluates it; typeahead before a prompt can be dropped. */ -const TERMINAL_PROBE_RESEND_MS = 15_000 - -/** - * Opens a PTY as the session owner and waits for the shell to evaluate what it was sent. Returns - * the multiplexer's disposer: until called, the session keeps answering relay keepalives, as the - * app does, so the relay never reaps it as silent while the connection is still up. - */ -export async function assertTerminalEchoes( - deployed: RelayDeployResult, - clientInstanceId: string, - probe: TerminalProbe -): Promise<() => void> { - const mux = new SshChannelMultiplexer(deployed.transport) - let keepOpen = false - try { - // Why retry: the first connect's owner stays held for its grace period, and the app retries too. - await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - clientInstanceId, - expectedServerBuildId: deployed.serverBuildId - }), - { isCurrent: () => true, onClosed: () => () => {} } - ) - const output = new Map() - mux.onNotificationByMethod('pty.data', (params) => { - if (typeof params.id === 'string' && typeof params.data === 'string') { - output.set(params.id, (output.get(params.id) ?? '') + params.data) - } - }) - const spawned: unknown = await mux.request('pty.spawn', { cols: 80, rows: 24 }) - const id = - spawned && typeof spawned === 'object' && 'id' in spawned && typeof spawned.id === 'string' - ? spawned.id - : '' - expect(id).not.toBe('') - // Why 90s: a first Windows PowerShell start under ConPTY can take tens of seconds on CI. - const deadline = Date.now() + 90_000 - let nextSendAt = 0 - while (!(output.get(id) ?? '').includes(probe.expect) && Date.now() < deadline) { - if (Date.now() >= nextSendAt) { - mux.notify('pty.data', { id, data: probe.input }) - nextSendAt = Date.now() + TERMINAL_PROBE_RESEND_MS - } - await new Promise((resolve) => setTimeout(resolve, 200)) - } - expect(output.get(id) ?? '').toContain(probe.expect) - await mux.request('pty.shutdown', { id }) - keepOpen = true - return () => mux.dispose() - } finally { - if (!keepOpen) { - mux.dispose() - } - } -} - -export type PinnedRuntimeLayout = { nodePath: string; runtimeDir: string; storeDir: string } - -export function pinnedRuntimeLayout( - deployed: RelayDeployResult, - target: NodeRuntimeTarget -): PinnedRuntimeLayout { - if (!deployed.hostPlatform || !deployed.remoteRelayDir) { - throw new Error('A pinned deploy must report its host and relay directory') - } - // Remote paths join with `/` on every host flavor, Windows included. - const nodePath = pinnedRelayNodePath(deployed.hostPlatform, deployed.remoteRelayDir, target) - let runtimeDir = nodePath - const depth = orcadNodeRuntimeExecutable(target).split('/').length - for (let level = 0; level < depth; level++) { - runtimeDir = posix.dirname(runtimeDir) - } - return { nodePath, runtimeDir, storeDir: posix.dirname(runtimeDir) } -} - -/** - * Plants an older and a newer idle runtime beside the live one and collects with no client pin: - * only the reference and the running relay can keep the live runtime. - */ -async function assertGcKeepsInUseRuntime( - conn: SshConnection, - observer: HostileHostObserver, - deployed: RelayDeployResult, - layout: PinnedRuntimeLayout -): Promise { - const older = `node-${'a'.repeat(64)}` - const newer = `node-${'b'.repeat(64)}` - await observer.plantIdleRuntime(layout.storeDir, older, 'old') - await observer.plantIdleRuntime(layout.storeDir, newer, 'new') - const { hostPlatform, remoteHome } = deployed - if (!hostPlatform || !remoteHome) { - throw new Error('A pinned deploy must report its host and home') - } - let state = 'skipped' - // Why retry: the deploy's own background GC may still hold the store lock. - for (let attempt = 0; attempt < 20 && state !== 'collected'; attempt++) { - const result = await gcRemoteNodeRuntimeStore(conn, hostPlatform, remoteHome, { - currentPins: [] - }) - state = result.state - if (state !== 'collected') { - await new Promise((resolve) => setTimeout(resolve, 1_500)) - } - } - expect(state).toBe('collected') - expect(await observer.isFile(layout.nodePath)).toBe(true) - expect(await observer.exists(`${layout.storeDir}/${newer}`)).toBe(true) - expect(await observer.exists(`${layout.storeDir}/${older}`)).toBe(false) -} - -export type LaunchedCellRun = { - cell: HostileHostCellCore - observer: HostileHostObserver - sshTarget: SshTarget - terminal: TerminalProbe - first: DeployAttempt - firstConn: SshConnection - /** Extra per-deploy checks, e.g. the Windows command audit; `uploaded` is the first deploy. */ - inspectDeploy?: (attempt: DeployAttempt, uploaded: boolean) => void -} - -export type LaunchedCellEvidence = { nodePath: string; nodeSha256: string } - -export async function exerciseLaunchedCell(run: LaunchedCellRun): Promise { - const { cell, observer, sshTarget, terminal, first, firstConn } = run - if (cell.expect.outcome !== 'launched' || !first.deployed || !first.run) { - throw new Error(`${cell.id} did not launch a relay`) - } - // Rung B runs a compat runtime, which is what its store dir, sha and addon slot are keyed by. - const runtime = cell.expect.runtime ?? cell.expect.target - expect(first.run.selfTest).toBe('passed') - expect(first.run.runtimeTransfer).toBe('uploaded') - run.inspectDeploy?.(first, true) - const layout = pinnedRuntimeLayout(first.deployed, runtime) - const { executableSha256 } = pinnedNodeRuntimeAsset(runtime) - expect(posix.basename(layout.runtimeDir)).toBe(`node-${executableSha256}`) - const nodeSha256 = await observer.fileSha256(layout.nodePath) - expect(nodeSha256).toBe(executableSha256) - // Every slot file the relay loads, the Windows bundled ConPTY pair included, landed beside it. - for (const file of pinnedRelayAddonFiles(runtime)) { - expect(await observer.isFile(`${first.deployed.remoteRelayDir}/${file}`), file).toBe(true) - } - // Why one id for both connects: the app reconnects as the same client instance. - const clientInstanceId = randomUUID() - const closeFirstSession = await assertTerminalEchoes(first.deployed, clientInstanceId, terminal) - try { - await assertGcKeepsInUseRuntime(firstConn, observer, first.deployed, layout) - } finally { - closeFirstSession() - } - const before = await observer.fileStamp(layout.nodePath) - await firstConn.disconnect() - - const secondConn = await connectHostileHost(sshTarget) - try { - const second = await deployOnce(secondConn) - await assertCell(cell, observer, second) - expect(second.run?.runtimeTransfer).toBe('cached') - run.inspectDeploy?.(second, false) - expect(await observer.fileStamp(layout.nodePath)).toBe(before) - if (!second.deployed) { - throw new Error(`${cell.id} did not relaunch on the second connect`) - } - const closeSecondSession = await assertTerminalEchoes( - second.deployed, - clientInstanceId, - terminal - ) - closeSecondSession() - } finally { - await secondConn.disconnect() - } - return { nodePath: layout.nodePath, nodeSha256 } -} diff --git a/src/main/ssh/ssh-hostile-hosts.docker.test.ts b/src/main/ssh/ssh-hostile-hosts.docker.test.ts new file mode 100644 index 000000000000..cc1f3033535a --- /dev/null +++ b/src/main/ssh/ssh-hostile-hosts.docker.test.ts @@ -0,0 +1,64 @@ +// Design D5/D6 hostile-host matrix: the real client-side relay deploy against container SSH +// targets, and against a macOS runner's own loopback sshd, asserting which rung of the runtime +// ladder each host lands on, and for cells naming one, the runtime managed orcad deploys on. +// +// Run: ORCA_RUN_SSH_HOSTILE_HOSTS=1 pnpm test src/main/ssh/ssh-hostile-hosts.docker.test.ts +// Needs `pnpm build:relay` and an orcad template holding each selected cell's slot. Docker cells +// need Linux Docker (the no-egress cell dials an internal bridge directly) and +// node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl +// macOS cells need /usr/sbin/sshd and this runner's slot (`pnpm build:orcad-prebuilds`, then +// `--targets darwin-arm64` or `darwin-x64`). Only cells this machine can host run. +// ORCA_SSH_HOSTILE_HOST_CELLS=id,id narrows the run. .github/workflows/ssh-hostile-hosts.yml runs it. +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) + +import { HOSTILE_HOST_CELLS, selectHostileHostCells } from './ssh-hostile-host-cells' +import { proveManagedOrcadCell } from './ssh-hostile-host-managed-orcad' +import { installHostileHostAppEnvironment } from './ssh-hostile-host-test-harness' +import { + hostileHostSshTarget, + startHostileHostTarget, + stopHostileHostTarget, + type HostileHostTarget +} from './ssh-hostile-host-test-fixture' + +const RUN = process.env.ORCA_RUN_SSH_HOSTILE_HOSTS === '1' +const SELECTED = new Set( + RUN ? selectHostileHostCells(process.env.ORCA_SSH_HOSTILE_HOST_CELLS).map((cell) => cell.id) : [] +) +const CELL_TIMEOUT_MS = 15 * 60_000 + +describe('SSH hostile-host managed orcad matrix', () => { + let cleanupAppEnvironment: (() => void) | null = null + + beforeAll(() => { + cleanupAppEnvironment = installHostileHostAppEnvironment() + }) + + afterAll(() => { + cleanupAppEnvironment?.() + }) + + // Why: with every cell skipped the job would pass having deployed nothing. + it.runIf(RUN)('selects at least one cell this machine can host', () => { + expect(SELECTED.size).toBeGreaterThan(0) + }) + + // A fresh host per cell. + for (const cell of HOSTILE_HOST_CELLS.filter((candidate) => candidate.managed)) { + it.skipIf(!SELECTED.has(cell.id))( + `${cell.id} deploys managed orcad on ${cell.managed?.runtime} (${cell.managed?.outcome})`, + async () => { + let target: HostileHostTarget | null = null + try { + target = await startHostileHostTarget(cell) + await proveManagedOrcadCell(cell, target, hostileHostSshTarget(target)) + } finally { + await stopHostileHostTarget(target) + } + }, + CELL_TIMEOUT_MS + ) + } +}) diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts deleted file mode 100644 index 7a503f518677..000000000000 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -// #9819, the client half: what the sweep actually asks the store and the host, and what it does -// with the answers. The rule itself is covered in ssh-relay-pty-ownership-proof.test.ts. -import { describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import type { SshRemotePtyLease } from '../../shared/ssh-types' -import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' -import type { PersistedState } from '../../shared/persisted-state-types' -import { - upsertSshRemotePtyLease, - type SshPtyLeaseOperations -} from '../persistence/leasing-ssh-ptys/ssh-pty-lease-operations' -import { - RELAY_PTY_SWEEP_PASS_BUDGET_MS, - sweepOrphanedRelayPtys -} from './ssh-orphan-relay-pty-sweep' -import { RELAY_PTY_SWEEP_MIN_AGE_MS } from '../../shared/ssh-relay-pty-ownership-proof' - -const TARGET = 'target-1' -const OURS = 'client-instance-ours' -// A stable pane id is a UUID; anything else is stripped before supersede can match on it. -const LEAF = '11111111-2222-4333-8444-555555555555' - -const OBSERVATION = { authorityGeneration: 'gen-1', observationEpoch: 1, capturedAgeMs: 0 } - -/** The host looked and saw its own shell owning the terminal: nothing is running in the pane. */ -function idleShell(): ForegroundProcessEvidence { - return { ...OBSERVATION, verdict: 'live', processName: null, shellOwnsEveryTtyProcessGroup: true } -} - -function hostEntry(overrides: Partial = {}): PtyProcessInfo { - return { - id: `ssh:${TARGET}@@pty-1`, - incarnationId: 'inc-1', - cwd: '/home/user', - title: 'zsh', - ownerClientInstanceId: OURS, - hostAgeMs: RELAY_PTY_SWEEP_MIN_AGE_MS * 2, - paneBound: true, - foregroundProcessEvidence: idleShell(), - ...overrides - } -} - -function createHarness( - processes: PtyProcessInfo[], - leases: SshRemotePtyLease[] = [] -): { provider: IPtyProvider; store: Store; shutdown: ReturnType } { - const shutdown = vi.fn().mockResolvedValue(undefined) - const provider = { - listProcesses: vi.fn().mockResolvedValue(processes), - shutdown - } as unknown as IPtyProvider - const store = { - getSshRemotePtyLeases: vi.fn().mockReturnValue(leases), - reconcileSshRemotePtyLeasesForTarget: vi.fn() - } as unknown as Store - return { provider, store, shutdown } -} - -function run( - harness: ReturnType, - overrides: Partial[0]> = {} -): Promise { - return sweepOrphanedRelayPtys({ - targetId: TARGET, - store: harness.store, - provider: harness.provider, - clientInstanceId: OURS, - isSessionOwner: true, - routedPtyIds: [], - shouldContinue: () => true, - ...overrides - }) -} - -function lease(ptyId: string, state: SshRemotePtyLease['state']): SshRemotePtyLease { - return { ptyId, state } as SshRemotePtyLease -} - -describe('sweepOrphanedRelayPtys', () => { - it('stops an attested orphan, fenced on the incarnation the same listing published', async () => { - const harness = createHarness([hostEntry()]) - - await run(harness) - - expect(harness.shutdown).toHaveBeenCalledWith( - `ssh:${TARGET}@@pty-1`, - expect.objectContaining({ immediate: true, expectedIncarnationId: 'inc-1' }) - ) - }) - - it('asks the host to re-check ownership on the one call that cannot be undone', async () => { - // The stop is the only irreversible step in this flow, and until now the whole nine-condition - // rule was enforced only here, on the client that decided to make it. Naming the owner makes - // the host re-decide where the processes actually live. - const harness = createHarness([hostEntry()]) - - await run(harness) - - expect(harness.shutdown).toHaveBeenCalledWith( - `ssh:${TARGET}@@pty-1`, - expect.objectContaining({ expectedOwnerClientInstanceId: OURS }) - ) - }) - - it('does not act on an observation that aged out between the listing and the plan', async () => { - // The listing answered inside the budget, but this pass then spent longer than the evidence is - // good for. Staleness has to degrade to "leave it running". - let clock = 1_000_000 - const harness = createHarness([hostEntry()]) - harness.provider.listProcesses = vi.fn().mockImplementation(async () => { - clock += 1 - return [hostEntry()] - }) - - const pass = (maximumEvidenceAgeMs: number): Promise => - run(harness, { - now: () => clock, - maximumEvidenceAgeMs, - passBudgetMs: 60_000, - shouldContinue: () => { - clock += 20 - return true - } - }) - - await pass(10) - expect(harness.shutdown).not.toHaveBeenCalled() - - // Positive control: the same entry, the same elapsed time, a budget that covers it. - await pass(10_000) - expect(harness.shutdown).toHaveBeenCalledTimes(1) - }) - - it('leaves a PTY the caller just reattached alone', async () => { - const harness = createHarness([hostEntry()]) - - await run(harness, { routedPtyIds: ['pty-1'] }) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it.each([['attached'], ['detached']] as const)( - 'leaves a PTY holding a live %s lease alone', - async (state) => { - const harness = createHarness([hostEntry()], [lease('pty-1', state)]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - } - ) - - it('leaves a PTY with an undelivered stop to the replay pass', async () => { - // The kill-intent journal owns those: it re-fences and retries them, and a second stop issued - // from here would race that decision with weaker evidence. - const tombstoned = { - ...lease('pty-1', 'terminated'), - pendingKill: { requestedAt: 1, incarnationId: 'inc-1', attempts: 0 } - } as SshRemotePtyLease - const harness = createHarness([hostEntry()], [tombstoned]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('leaves a PTY whose lease this client expired alone', async () => { - // The reversal this guards: supersedeSiblingLeasesForPane, dropStalePty and the missing-surface - // refusal all write `expired` precisely BECAUSE they will not stop the remote process. - const harness = createHarness([hostEntry()], [lease('pty-1', 'expired')]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('leaves a PTY whose expired lease names a recycled relay id alone', async () => { - // `relayIdRecycled` is the one expired lease the reattach predicate refuses, so it is the case - // most likely to be mistaken for a licence to kill. The sweep asks a different question: this - // id now names some OTHER incarnation, which makes a stop more dangerous, not less. - const harness = createHarness( - [hostEntry()], - [{ ...lease('pty-1', 'expired'), relayIdRecycled: true }] - ) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('leaves alone a lease the real supersede path expired when a pane re-leased', async () => { - // Drives the actual persistence operation rather than asserting the state by hand, so this - // stays true only while supersede really does leave the predecessor's process running. - const state: PersistedState = { - sshRemotePtyLeases: [ - { - targetId: TARGET, - ptyId: 'pty-1', - state: 'attached', - worktreeId: 'wt-1', - leafId: LEAF, - createdAt: 1, - updatedAt: 1 - } - ] - } as unknown as PersistedState - const operations: SshPtyLeaseOperations = { - state, - toStoredPtyId: (_targetId, ptyId) => ptyId, - toComparablePtyId: (_targetId, ptyId) => ptyId, - clearBindingsForTarget: () => {}, - clearBindingsForLeases: () => false, - flush: () => {}, - runDurableMutation: async (mutate) => mutate().value - } - // The same pane re-leases under a new relay id; pty-1 is expired, never terminated. - upsertSshRemotePtyLease(operations, { - targetId: TARGET, - ptyId: 'pty-2', - state: 'attached', - worktreeId: 'wt-1', - leafId: LEAF - }) - expect(state.sshRemotePtyLeases?.find((entry) => entry.ptyId === 'pty-1')?.state).toBe( - 'expired' - ) - const harness = createHarness([hostEntry()], state.sshRemotePtyLeases ?? []) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('forwards the host foreground observation, so a busy pane is never swept', async () => { - // A `claude` the user launched by hand: Orca registered no agent session, so the entry carries - // no agentSessionOwners and only the host's own observation can save it. - const harness = createHarness([ - hostEntry({ - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'live', - processName: 'claude', - shellOwnsEveryTtyProcessGroup: false - } - }) - ]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('bounds the listing and every stop with one connect budget', async () => { - const harness = createHarness([hostEntry()]) - const start = 1_000_000 - - await run(harness, { now: () => start }) - - const deadline = vi.mocked(harness.provider.listProcesses).mock.calls[0]?.[0]?.deadlineMs - expect(deadline).toBe(start + RELAY_PTY_SWEEP_PASS_BUDGET_MS) - expect(harness.shutdown).toHaveBeenCalledWith( - `ssh:${TARGET}@@pty-1`, - expect.objectContaining({ deadlineMs: deadline }) - ) - }) - - it('does sweep a PTY whose lease this client already tombstoned without an order', async () => { - const harness = createHarness([hostEntry()], [lease('pty-1', 'terminated')]) - - await run(harness) - - expect(harness.shutdown).toHaveBeenCalledTimes(1) - }) - - it('asks the host nothing when this connection is not the session owner', async () => { - const harness = createHarness([hostEntry()]) - - await run(harness, { isSessionOwner: false }) - - expect(harness.provider.listProcesses).not.toHaveBeenCalled() - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('stops nothing against a host that publishes no attestation', async () => { - const legacy = hostEntry() - delete legacy.ownerClientInstanceId - delete legacy.hostAgeMs - delete legacy.paneBound - const harness = createHarness([legacy]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('swallows a failed listing rather than failing the connect it runs on', async () => { - const harness = createHarness([]) - vi.mocked(harness.provider.listProcesses).mockRejectedValue(new Error('relay went away')) - - await expect(run(harness)).resolves.toBeUndefined() - }) - - it('swallows a failed stop and leaves the order to the next connect', async () => { - const harness = createHarness([hostEntry()]) - harness.shutdown.mockRejectedValue(new Error('connection lost')) - - await expect(run(harness)).resolves.toBeUndefined() - }) - - it('abandons the pass when the attempt is superseded mid-flight', async () => { - const harness = createHarness([hostEntry()]) - let alive = true - vi.mocked(harness.provider.listProcesses).mockImplementation(async () => { - alive = false - return [hostEntry()] - }) - - await run(harness, { shouldContinue: () => alive }) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.ts deleted file mode 100644 index def6de6ebb45..000000000000 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { - planRelayPtySweep, - RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - RELAY_PTY_SWEEP_MIN_AGE_MS, - type RelayPtyOwnershipEvidence -} from '../../shared/ssh-relay-pty-ownership-proof' - -export type SshOrphanRelayPtySweepArgs = { - targetId: string - store: Store - provider: IPtyProvider - /** This client's persisted consumer identity for the target. */ - clientInstanceId: string - /** True only when the relay granted this connection the negotiated `session-owner` role. */ - isSessionOwner: boolean - /** Relay PTY ids this connect just reattached, plus any the caller otherwise knows are live. */ - routedPtyIds: Iterable - shouldContinue: () => boolean - now?: () => number - minimumHostAgeMs?: number - /** Absolute budget for the whole pass, in ms from its start. */ - passBudgetMs?: number - maximumEvidenceAgeMs?: number -} - -/** The two client-side claims the plan needs, read in one pass over the leases. - * - * `routed` is every relay PTY id this client still has a route to: a live lease, an id this - * connect reattached, or a stop it recorded and has not delivered. - * - * `expired` is separate on purpose. It is written by four paths, and every one of them - * deliberately leaves the remote process running: a pane re-leasing under a new relay id, a pane - * surface missing from the layout, a retired reattach, and a reattach the HOST answered "not - * found" for. Only the second of those is reached with the process provably alive — the layout - * refusal runs after `pty.attach` already succeeded (`restoreReattachedPtyRuntime`), which is - * precisely why its own comment reads "topology absence alone is not authority to kill a - * process". A reattach that failed on the transport writes nothing at all: it early-returns as - * `reattachAttemptsExhausted` and the lease stays `attached`, hence routed. - * - * Folding it into `routed` would work, but it would also lose the reason in the skip log, and this - * is the distinction the sweep most needs to be able to explain. - * - * Deliberately the raw state rather than `sshRemotePtyLeaseAllowsReattach`: that predicate answers - * "may this lease be reattached", and this asks "may this client stop the process". Every non- - * `terminated` state answers no either way, so sorting the marked leases (`supersededBy`, - * `relayIdRecycled`) into `routed` instead would move nothing but the skip reason — and both marks - * are written by paths that leave the remote process running on purpose, so they must keep - * refusing the stop rather than authorizing one. */ -function clientClaims(args: SshOrphanRelayPtySweepArgs): { - routed: Set - expired: Set -} { - const routed = new Set(args.routedPtyIds) - const expired = new Set() - for (const lease of args.store.getSshRemotePtyLeases(args.targetId)) { - if (lease.state === 'expired') { - expired.add(lease.ptyId) - } else if (lease.state !== 'terminated') { - routed.add(lease.ptyId) - } - if (lease.pendingKill) { - routed.add(lease.ptyId) - } - } - return { routed, expired } -} - -function toEvidence( - targetId: string, - process: Awaited>[number] -): RelayPtyOwnershipEvidence { - return { - ptyId: toRelaySshPtyId(targetId, process.id), - ...(process.incarnationId ? { incarnationId: process.incarnationId } : {}), - ...(process.ownerClientInstanceId - ? { ownerClientInstanceId: process.ownerClientInstanceId } - : {}), - ...(typeof process.hostAgeMs === 'number' ? { hostAgeMs: process.hostAgeMs } : {}), - ...(typeof process.paneBound === 'boolean' ? { paneBound: process.paneBound } : {}), - ...(process.agentSessionOwners ? { agentSessionOwners: process.agentSessionOwners } : {}), - ...(process.foregroundProcessEvidence - ? { foregroundProcessEvidence: process.foregroundProcessEvidence } - : {}) - } -} - -/** One budget for the whole pass, because this is opportunistic cleanup bolted onto the most - * latency-sensitive and most failure-prone path in the app (#14830, #17830). Without it the - * listing and up to eight stops inherit the mux default and connect waits on all of them. - * Overrunning it yields an empty pass — the same outcome as finding nothing, never a failed - * connect. */ -export const RELAY_PTY_SWEEP_PASS_BUDGET_MS = 5_000 - -/** Stops the relay PTYs this client can prove it created and has since lost every route to. - * - * Runs after reattach, so a PTY this connect reclaimed is already routed and can never be a - * candidate. Best-effort and never throws: it is opportunistic cleanup on the connect path, and a - * failed connection is a much worse outcome than a slot left leaked for another session. - * - * Costs one `pty.listProcesses` per connect. That is the price of reconciling at all — there is no - * cheaper question than asking the authoritative host what it is holding. */ -export async function sweepOrphanedRelayPtys(args: SshOrphanRelayPtySweepArgs): Promise { - if (!args.isSessionOwner || !args.clientInstanceId || !args.shouldContinue()) { - return - } - const now = args.now ?? Date.now - const deadlineMs = now() + (args.passBudgetMs ?? RELAY_PTY_SWEEP_PASS_BUDGET_MS) - try { - const processes = await args.provider.listProcesses({ deadlineMs }) - // The instant the host's observations reached this client. Every later step — reading the - // leases, planning, issuing the stops — ages them, and the plan has to see that age. - const listedAtMs = now() - if (!args.shouldContinue() || now() >= deadlineMs) { - return - } - const claims = clientClaims(args) - const plan = planRelayPtySweep( - processes.map((process) => toEvidence(args.targetId, process)), - { - clientInstanceId: args.clientInstanceId, - isSessionOwner: args.isSessionOwner, - routedPtyIds: claims.routed, - expiredLeasePtyIds: claims.expired, - minimumHostAgeMs: args.minimumHostAgeMs ?? RELAY_PTY_SWEEP_MIN_AGE_MS, - evidenceAgeSinceListingMs: Math.max(0, now() - listedAtMs), - maximumEvidenceAgeMs: args.maximumEvidenceAgeMs ?? RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS - } - ) - if (plan.sweep.length === 0) { - return - } - await Promise.all( - plan.sweep.map(async (target) => { - if (!args.shouldContinue()) { - return - } - try { - // Two fences, both enforced by the host that owns the process. The incarnation stops a - // relay that renumbered its ids between the read and this call from hitting a stranger; - // the owner id makes the host re-check the ownership rule itself, so the one irreversible - // call in this flow is not authorized by the client alone. - await args.provider.shutdown(toAppSshPtyId(args.targetId, target.ptyId), { - immediate: true, - deadlineMs, - expectedIncarnationId: target.incarnationId, - expectedOwnerClientInstanceId: args.clientInstanceId - }) - console.log( - `[ssh-orphan-sweep] stopped orphaned relay PTY ${args.targetId}/${target.ptyId}` - ) - } catch (err) { - // Unverifiable, not failed: the next connect re-reads the inventory and decides again. - console.warn( - `[ssh-orphan-sweep] stop for ${args.targetId}/${target.ptyId} is unverifiable: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } - }) - ) - } catch (err) { - console.warn( - `[ssh-orphan-sweep] pass on ${args.targetId} stopped early: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } -} diff --git a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts b/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts deleted file mode 100644 index 9bb42a2cb276..000000000000 --- a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -// The one test that spans both halves of the sweep. Every other test in this feature asserts on a -// hand-written `ForegroundProcessEvidence` literal, which is exactly how a foreground-only idle -// predicate survived review: the literals said `shellIsForeground: true` for an idle shell because -// that is what the author believed, and nothing ever produced one from a real process table. -// -// So this runs the REAL publisher (`resolveAgentForegroundProcessesBatch` -> -// `toForegroundProcessEvidence`, what `pty.listProcesses` calls) against the REAL client reader -// (`planRelayPtySweep`), over `ps` output captured verbatim from a Linux container driving a real -// `bash -i` on a real pty. The fixtures below are transcripts, not constructions. -// -// Read the shell's own row in each fixture. In `background` and `ctrlz` it is -// `pgid == tpgid`, `Ss+` — byte-identical to `idle`. That is the defect: a foreground-only -// predicate cannot see a job the user backgrounded or suspended, and the stop it authorizes -// SIGKILLs every process group on the tty. -import { describe, expect, it } from 'vitest' -import { - resolveAgentForegroundProcessesBatch, - toForegroundProcessEvidence -} from '../providers/agent-foreground-process-batch' -import { parseStrictProcessTableRows } from '../../shared/process-table-snapshot' -import { - planRelayPtySweep, - RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - RELAY_PTY_SWEEP_MIN_AGE_MS, - type RelayPtySweepContext -} from '../../shared/ssh-relay-pty-ownership-proof' - -const OURS = 'client-instance-ours' - -/** `ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=` on debian:bookworm-slim, one capture per pane - * state, each with a `bash -i` on a pty forked by the harness. */ -const CAPTURES = { - /** Nothing running. The only sweepable state. */ - idle: { - rootPid: 3150, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3150 1 3150 3150 Ss+ bash -i', - ' 3151 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `sleep 300` in the foreground. The shell's tpgid moved off its own pgid. */ - foreground: { - rootPid: 3155, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3155 1 3155 3156 Ss bash -i', - ' 3156 3155 3156 3156 S+ sleep 300', - ' 3157 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `sleep 300 &`. The shell reads IDENTICALLY to `idle`; only the job's own row differs. */ - background: { - rootPid: 3152, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3152 1 3152 3152 Ss+ bash -i', - ' 3153 3152 3153 3152 S sleep 300', - ' 3154 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `sleep 300` then Ctrl-Z. The shell again reads IDENTICALLY to `idle`. */ - ctrlz: { - rootPid: 3158, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3158 1 3158 3158 Ss+ bash -i', - ' 3159 3158 3159 3158 T sleep 300', - ' 3160 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `set +m; sleep 300 &`. With job control OFF the job does not get its own process group — it - * keeps the SHELL's pgid. So the tty carries exactly one process group, and that group is - * running a build. Reproduced independently on a real Ubuntu host through an Orca pane. */ - setMinusMBackground: { - rootPid: 12, - table: [ - ' 1 0 1 -1 Ss /bin/bash /work/run.sh', - ' 11 1 1 -1 S python3 /work/pty-scenario.py setm_background', - ' 12 11 12 12 Ss+ bash -i', - ' 13 12 12 12 S+ sleep 300', - ' 14 11 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** A `set +m` job that drops its controlling terminal (`ioctl(TIOCNOTTY)` with no `setsid`). It - * keeps the shell's pgid, reports `tpgid == -1`, and is absent from `ps -t ` and from every - * tty-keyed index — while `killpg(shellPgid)` still reaches it. */ - nottyGroupMember: { - rootPid: 16, - table: [ - ' 1 0 1 -1 Ss /bin/bash /work/run.sh', - ' 15 1 1 -1 S python3 /work/pty-scenario.py notty_member', - ' 16 15 16 16 Ss+ bash -i', - ' 17 16 16 -1 S python3 -c import fcntl,os,time;fd=os.open("/dev/tty",os.O_RDWR);fcntl.ioctl(fd,0x5422);os.close(fd);time.sleep(300)', - ' 18 15 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** A `set +m` job that double-forks. pid 22 keeps the shell's pgid and tty but reparented to pid - * 1, so the ppid walk from `rootPid` never reaches it and it can never be named. */ - doubleForkedGroupMember: { - rootPid: 20, - table: [ - ' 1 0 1 -1 Ss /bin/bash /work/run.sh', - ' 19 1 1 -1 S python3 /work/pty-scenario.py double_fork', - ' 20 19 20 20 Ss+ bash -i', - ' 22 1 20 20 S+ python3 -c import os,sys,time;p=os.fork() if p: print("GRANDCHILD:%d"%p);sys.stdout.flush();os._exit(0) time.sleep(300)', - ' 23 19 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - } -} as const - -function context(overrides: Partial = {}): RelayPtySweepContext { - return { - clientInstanceId: OURS, - isSessionOwner: true, - routedPtyIds: new Set(), - expiredLeasePtyIds: new Set(), - minimumHostAgeMs: RELAY_PTY_SWEEP_MIN_AGE_MS, - evidenceAgeSinceListingMs: 0, - maximumEvidenceAgeMs: RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - ...overrides - } -} - -/** Everything the host does between reading `ps` and putting a record on the wire. */ -async function publish( - capture: { rootPid: number; table: readonly string[] }, - capturedAgeMs = 0 -): Promise> { - const rows = parseStrictProcessTableRows(capture.table.join('\n')) - const [result] = await resolveAgentForegroundProcessesBatch( - [{ rootPid: capture.rootPid, fallbackProcess: 'bash' }], - { rows } - ) - return toForegroundProcessEvidence(result, { - authorityGeneration: 'relay-generation-1', - observationEpoch: 1, - capturedAgeMs - }) -} - -/** Everything the client does with that record. Returns the plan for one orphan entry. */ -async function planFor( - capture: { rootPid: number; table: readonly string[] }, - overrides: { capturedAgeMs?: number; context?: Partial } = {} -): Promise> { - return planRelayPtySweep( - [ - { - ptyId: 'pty-1', - incarnationId: 'inc-1', - ownerClientInstanceId: OURS, - hostAgeMs: RELAY_PTY_SWEEP_MIN_AGE_MS * 2, - paneBound: true, - foregroundProcessEvidence: await publish(capture, overrides.capturedAgeMs) - } - ], - context(overrides.context) - ) -} - -function skipReason(plan: ReturnType): string | undefined { - return plan.skipped.find((entry) => entry.ptyId === 'pty-1')?.reason -} - -describe('what the host publishes about a pane, read by the sweep', () => { - it('records that a backgrounded and a suspended shell are indistinguishable at tpgid/pgid', () => { - // The premise of the whole file. If this ever fails, the fixtures drifted and every verdict - // below is testing something other than the defect. Pids differ between captures, so the - // comparison is of the shell row's signature: who its parent is, whether it leads its own - // process group, whether that group owns the terminal, and its state flags. - const shellRowSignature = (capture: { rootPid: number; table: readonly string[] }): string => { - const row = parseStrictProcessTableRows(capture.table.join('\n')).find( - (candidate) => candidate.pid === capture.rootPid - )! - return [ - `ppid=${row.ppid}`, - `leadsOwnGroup=${row.pgid === row.pid}`, - `ownsTerminal=${row.tpgid === row.pgid}`, - `stat=${row.stat}` - ].join(' ') - } - - expect(shellRowSignature(CAPTURES.idle)).toBe( - 'ppid=1 leadsOwnGroup=true ownsTerminal=true stat=Ss+' - ) - expect(shellRowSignature(CAPTURES.background)).toBe(shellRowSignature(CAPTURES.idle)) - expect(shellRowSignature(CAPTURES.ctrlz)).toBe(shellRowSignature(CAPTURES.idle)) - expect(shellRowSignature(CAPTURES.foreground)).not.toBe(shellRowSignature(CAPTURES.idle)) - - // Same premise for the `set +m` captures, minus `ppid`: their harness keeps its parent alive - // rather than reparenting the shell to init, and the ppid is the one field of the signature - // the predicate never reads. - const paneRowSignature = (capture: { rootPid: number; table: readonly string[] }): string => - shellRowSignature(capture).split(' ').slice(1).join(' ') - expect(paneRowSignature(CAPTURES.setMinusMBackground)).toBe(paneRowSignature(CAPTURES.idle)) - expect(paneRowSignature(CAPTURES.nottyGroupMember)).toBe(paneRowSignature(CAPTURES.idle)) - expect(paneRowSignature(CAPTURES.doubleForkedGroupMember)).toBe(paneRowSignature(CAPTURES.idle)) - }) - - it('sweeps an idle shell', async () => { - const evidence = await publish(CAPTURES.idle) - expect(evidence).toMatchObject({ - verdict: 'live', - processName: null, - shellOwnsEveryTtyProcessGroup: true - }) - - const plan = await planFor(CAPTURES.idle) - expect(plan.sweep).toEqual([{ ptyId: 'pty-1', incarnationId: 'inc-1' }]) - }) - - it('never sweeps a pane running a foreground job', async () => { - const evidence = await publish(CAPTURES.foreground) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.foreground) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane holding a backgrounded job', async () => { - // `sleep 300 &`, i.e. `pnpm build &` or `npm run dev &`. The shell handed the terminal back, - // so the pane looks idle; the job is alive in its own process group on the same tty and a - // stop would SIGKILL it. - const evidence = await publish(CAPTURES.background) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.background) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane holding a Ctrl-Z suspended job', async () => { - const evidence = await publish(CAPTURES.ctrlz) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.ctrlz) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - // The tty is not the unit the stop operates on. `forceKillPosixPtyProcessGroups` collects the - // groups on the tty and then `killpg`s each one, so anything sharing the shell's pgid dies with - // it — including members the tty index cannot see at all. All three captures below reproduce on - // real Linux: before the group-membership half of the predicate they published - // `shellOwnsEveryTtyProcessGroup: true`, planned a SWEEP, and the planted pid was GONE after the - // real `forceKillPosixPtyProcessGroups` call. - it('never sweeps a pane whose background job shares the shell pgid under `set +m`', async () => { - // pid 13 is `sleep 300` — stand in `pnpm build`. Its pgid IS the shell's, so the tty carries - // exactly one process group and the tty half of the predicate reads the pane as idle. - const rows = parseStrictProcessTableRows(CAPTURES.setMinusMBackground.table.join('\n')) - const tty = rows.filter((row) => row.tpgid === CAPTURES.setMinusMBackground.rootPid) - expect(new Set(tty.map((row) => row.pgid))).toEqual(new Set([12])) - expect(tty.map((row) => row.pid)).toEqual([12, 13]) - - const evidence = await publish(CAPTURES.setMinusMBackground) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.setMinusMBackground) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane whose group member dropped the controlling terminal', async () => { - // pid 17 kept the shell's pgid and called `ioctl(TIOCNOTTY)`, so it reports `tpgid == -1`, - // never appears in `ps -t `, and no tty-shaped index — not process groups, not pids — - // can observe it. `killpg(16)` reaches it regardless. - const rows = parseStrictProcessTableRows(CAPTURES.nottyGroupMember.table.join('\n')) - expect(rows.filter((row) => row.tpgid === 16).map((row) => row.pid)).toEqual([16]) - expect(rows.filter((row) => row.pgid === 16).map((row) => row.pid)).toEqual([16, 17]) - - const evidence = await publish(CAPTURES.nottyGroupMember) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.nottyGroupMember) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane whose group member double-forked away from the shell', async () => { - // pid 22 reparented to pid 1, so the ppid walk from rootPid cannot reach it and the named- - // process backstop can never fire. It still holds the shell's pgid. - const rows = parseStrictProcessTableRows(CAPTURES.doubleForkedGroupMember.table.join('\n')) - expect(rows.find((row) => row.pid === 22)).toMatchObject({ ppid: 1, pgid: 20, tpgid: 20 }) - - const evidence = await publish(CAPTURES.doubleForkedGroupMember) - expect(evidence).toMatchObject({ processName: null, shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.doubleForkedGroupMember) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('refuses an observation older than the pass it would authorize', async () => { - // Same idle capture that sweeps above; only its age differs. Staleness degrades to "leave it - // running", never to "stop it". - const stale = await planFor(CAPTURES.idle, { - capturedAgeMs: RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS + 1 - }) - expect(stale.sweep).toEqual([]) - expect(skipReason(stale)).toBe('host foreground observation is too old to authorize a stop') - - // And the client's own share of the age counts: a host stamp inside the budget still ages out - // while this pass reads leases and plans. - const agedOnTheClient = await planFor(CAPTURES.idle, { - capturedAgeMs: RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - context: { evidenceAgeSinceListingMs: 1 } - }) - expect(agedOnTheClient.sweep).toEqual([]) - expect(skipReason(agedOnTheClient)).toBe( - 'host foreground observation is too old to authorize a stop' - ) - }) - - it('never sweeps when the host itself is too degraded to answer', async () => { - // `main` has since added `recoverRemoteTerminalRuntime`, a self-driven reconnect on relay - // node-pty failure — a sweep trigger that fires exactly when the host is unwell. The publisher - // has to fail closed there: a capture that cannot locate the shell is `unverifiable`, which is - // its own verdict and never collapses into "idle" (docs/reference/ssh-execution-boundary.md). - const evidence = await publish({ rootPid: 999_999, table: CAPTURES.idle.table }) - expect(evidence).toMatchObject({ verdict: 'unverifiable', reason: 'root_missing' }) - - const plan = await planFor({ rootPid: 999_999, table: CAPTURES.idle.table }) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host could not observe the pane foreground process') - }) - - it('still reclaims a shell whose work really did finish', async () => { - // The feature must not degrade into a no-op. The background fixture's job is gone; what is - // left is the same orphaned shell, and it is swept. - const finished = { - rootPid: CAPTURES.background.rootPid, - table: CAPTURES.background.table.filter((line) => !line.includes('sleep 300')) - } - const plan = await planFor(finished) - expect(plan.sweep).toEqual([{ ptyId: 'pty-1', incarnationId: 'inc-1' }]) - }) -}) diff --git a/src/main/ssh/ssh-owner-admission-blocked-error.ts b/src/main/ssh/ssh-owner-admission-blocked-error.ts deleted file mode 100644 index 35055cb4880c..000000000000 --- a/src/main/ssh/ssh-owner-admission-blocked-error.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Why a typed error instead of raw transport classification: another authenticated connection holds -// the PTY session owner claim, and no relay redeploy or backoff attempt can reconcile that. The -// reconnect ladder has to stop and say what actually happened rather than blame the link. - -export class SshOwnerAdmissionBlockedError extends Error { - readonly name = 'SshOwnerAdmissionBlockedError' - - constructor(targetId: string, options?: { cause?: unknown }) { - super( - `Another connection currently owns the remote terminals for ${targetId}. ` + - `Disconnect that client, or wait for it to release ownership, before reconnecting.`, - options - ) - } -} - -export function isSshOwnerAdmissionBlockedError( - err: unknown -): err is SshOwnerAdmissionBlockedError { - return err instanceof SshOwnerAdmissionBlockedError -} diff --git a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts b/src/main/ssh/ssh-pending-pty-kill-replay.test.ts deleted file mode 100644 index 95296aa6434b..000000000000 --- a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay' -import { SshPtyAbsentFromRelayError } from '../providers/ssh-pty-errors' -import type { SshPendingPtyKillEntry } from '../../shared/ssh-pending-pty-kill' -import { - isSshPendingPtyKillExpired, - prunePendingSshPtyKills, - SSH_PENDING_PTY_KILL_TTL_MS -} from '../../shared/ssh-pending-pty-kill' -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' - -const TARGET = 'ssh-1' -const NOW = 1_800_000_000_000 - -type HostPty = { relayPtyId: string; incarnationId?: string } - -/** Models the real store rather than returning a fixed list: `getSshRemotePtyKillIntents` applies - * the same TTL filter production does, and the prune actually deletes. A stub that ignored `now` - * would make every TTL assertion below pass for the wrong reason. */ -function createStoreStub(entries: SshPendingPtyKillEntry[]): { - store: Store - cleared: string[] - terminated: string[] - expired: string[] - recycled: string[] - attempts: string[] - remaining: () => string[] -} { - let backing = [...entries] - const cleared: string[] = [] - const terminated: string[] = [] - const expired: string[] = [] - const recycled: string[] = [] - const attempts: string[] = [] - const store = { - getSshRemotePtyKillIntents: vi.fn((_target: string, now: number) => - prunePendingSshPtyKills(backing, now) - ), - pruneExpiredSshRemotePtyKillIntents: vi.fn((_target: string, now: number) => { - backing = backing.filter((item) => !isSshPendingPtyKillExpired(item.intent, now)) - }), - clearSshRemotePtyKillIntent: vi.fn((_target: string, ptyId: string) => { - backing = backing.filter((item) => item.ptyId !== ptyId) - cleared.push(ptyId) - }), - markSshRemotePtyLease: vi.fn( - (_target: string, ptyId: string, state: string, options?: { relayIdRecycled?: true }) => { - if (state === 'terminated') { - terminated.push(ptyId) - } else if (state === 'expired') { - expired.push(ptyId) - if (options?.relayIdRecycled) { - recycled.push(ptyId) - } - } - } - ), - noteSshRemotePtyKillReplayAttempt: vi.fn((_target: string, ptyId: string) => { - attempts.push(ptyId) - }) - } as unknown as Store - return { - store, - cleared, - terminated, - expired, - recycled, - attempts, - remaining: () => backing.map((item) => item.ptyId) - } -} - -/** A relay that lists `hostPtys`, and drops an id from that listing once it is shut down. */ -function createProviderStub(hostPtys: HostPty[], overrides: Partial = {}) { - const live = new Map(hostPtys.map((pty) => [pty.relayPtyId, pty.incarnationId])) - const shutdown = vi.fn(async (appPtyId: string) => { - live.delete(appPtyId.replace(`ssh:${TARGET}@@`, '')) - }) - const listProcesses = vi.fn(async () => - Array.from(live, ([relayPtyId, incarnationId]) => ({ - id: `ssh:${TARGET}@@${relayPtyId}`, - ...(incarnationId ? { incarnationId } : {}) - })) - ) - return { - provider: { listProcesses, shutdown, ...overrides } as unknown as IPtyProvider, - listProcesses, - shutdown - } -} - -function entry(relayPtyId: string, incarnationId: string, requestedAt = NOW) { - return { ptyId: relayPtyId, intent: { requestedAt, incarnationId, attempts: 0 } } -} - -describe('replayPendingSshPtyKills', () => { - beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - it('does not talk to the host when nothing is pending', async () => { - const { store } = createStoreStub([]) - const { provider, listProcesses } = createProviderStub([]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(listProcesses).not.toHaveBeenCalled() - }) - - // This is the leak: yesterday nothing retried, and the shell stayed up on the user's box. - it('replays the undelivered stop against the same PTY the kill was aimed at', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-1', { - immediate: true, - expectedIncarnationId: 'inc-a' - }) - expect(cleared).toEqual(['pty-1']) - expect(terminated).toEqual(['pty-1']) - }) - - // An offline close across a relaunch never learned the incarnation; the epoch-scoped id is the fence. - it('replays an epoch-scoped stop that carries no incarnation', async () => { - const relayPtyId = 'pty2:epoch-a:4' - const { store, cleared, terminated } = createStoreStub([ - { ptyId: relayPtyId, intent: { requestedAt: NOW, attempts: 0 } } - ]) - const { provider, shutdown } = createProviderStub([{ relayPtyId, incarnationId: 'inc-live' }]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).toHaveBeenCalledWith(`ssh:ssh-1@@${relayPtyId}`, { - immediate: true, - expectedIncarnationId: undefined - }) - expect(cleared).toEqual([relayPtyId]) - expect(terminated).toEqual([relayPtyId]) - }) - - // #16970: a redeployed relay renumbers from pty-1, so this id now names someone else's shell. - it('refuses to kill a recycled relay id and expires the lease that named it', async () => { - const { store, cleared, terminated, expired, recycled } = createStoreStub([ - entry('pty-1', 'inc-a') - ]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-fresh' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual(['pty-1']) - // Declining to kill is only half of it: the reattach one step later fences on paneKey/tabId and - // never on incarnation, so an untouched lease would bind the user's old pane to that stranger. - expect(expired).toEqual(['pty-1']) - // `expired` alone no longer buys that: it also names orphans the reattach is meant to re-adopt, - // so the recycling has to be recorded on the lease itself. - expect(recycled).toEqual(['pty-1']) - // `expired`, not `terminated` — losing the route is not evidence the shell died. - expect(terminated).toEqual([]) - }) - - it('retires on the host reporting the PTY absent, with the tombstone that earns', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-2', incarnationId: 'inc-b' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual(['pty-1']) - expect(terminated).toEqual(['pty-1']) - }) - - // The TTL is owned by the durable prune, not by a branch in the decision function. This asserts - // the order is actually deleted and never aimed at whatever holds the id now — and it can only - // pass if the prune honours `now`, because the store stub applies the real TTL rules. - it('deletes an order past its TTL instead of replaying it, and never touches the host', async () => { - const { store, cleared, terminated, expired, remaining } = createStoreStub([ - entry('pty-1', 'inc-a') - ]) - const { provider, shutdown, listProcesses } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW + SSH_PENDING_PTY_KILL_TTL_MS + 1 - }) - expect(remaining()).toEqual([]) - expect(shutdown).not.toHaveBeenCalled() - expect(listProcesses).not.toHaveBeenCalled() - // Ageing out observes nothing, so it may not move the lease either way. - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - expect(expired).toEqual([]) - }) - - // #16763's lesson: `isPtyAlreadyGoneError` matches message text such as /Session not found/i, - // which a transport failure could wear. A tombstone must never rest on that — only on a listing. - it('does not tombstone on a shutdown error that merely looks like absence', async () => { - const { store, cleared, terminated, remaining } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], { - shutdown: vi.fn(async () => { - throw new SshPtyAbsentFromRelayError('SSH_SESSION_EXPIRED: pty-1') - }) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - expect(remaining()).toEqual(['pty-1']) - }) - - // Best-effort background work on the connect path: a disk hiccup must not fail the connection. - it('never rejects into its caller when persistence throws', async () => { - const { store } = createStoreStub([entry('pty-1', 'inc-a')]) - vi.mocked(store.noteSshRemotePtyKillReplayAttempt).mockImplementation(() => { - throw new Error('disk full') - }) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }]) - await expect( - replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - ).resolves.toBeUndefined() - }) - - it('never rejects into its caller when the very first store read throws', async () => { - const { store } = createStoreStub([entry('pty-1', 'inc-a')]) - vi.mocked(store.pruneExpiredSshRemotePtyKillIntents).mockImplementation(() => { - throw new Error('disk full') - }) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }]) - await expect( - replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - ).resolves.toBeUndefined() - }) - - it('keeps the order when the replayed stop is itself unverifiable', async () => { - const { store, cleared, terminated, attempts } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], { - shutdown: vi.fn(async () => { - throw new Error('socket closed') - }) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(attempts).toEqual(['pty-1']) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - // `pty.shutdown` returns the same empty success for a PTY the relay never had, so only a fresh - // inventory that omits the id is a death certificate. - it('keeps the order when the host still lists the PTY after a resolved shutdown', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], { - shutdown: vi.fn(async () => {}) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - // Wire skew: a host predating the published PTY incarnation cannot answer the fence. - it('defers against a host that publishes no PTY incarnation', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, shutdown } = createProviderStub([{ relayPtyId: 'pty-1' }]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - it('keeps every order when the inventory itself fails', async () => { - const { store, cleared, terminated } = createStoreStub([ - entry('pty-1', 'inc-a'), - entry('pty-2', 'inc-b') - ]) - const { provider } = createProviderStub([], { - listProcesses: vi.fn(async () => { - throw new Error('relay unreachable') - }) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - it('delivers nothing once the connection attempt is superseded', async () => { - const { store, cleared } = createStoreStub([entry('pty-1', 'inc-a'), entry('pty-2', 'inc-b')]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' }, - { relayPtyId: 'pty-2', incarnationId: 'inc-b' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => false, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual([]) - }) - - // Bounded by wave, not by order: one inventory per wave of 4 plus one to prove the batch. The - // per-wave read is what keeps a stop's identity evidence within one round trip of the stop. - it('reads one inventory per wave plus one to confirm, not two per pending stop', async () => { - const pending = Array.from({ length: 12 }, (_, index) => entry(`pty-${index}`, `inc-${index}`)) - const { store, terminated } = createStoreStub(pending) - const { provider, listProcesses, shutdown } = createProviderStub( - pending.map((item) => ({ relayPtyId: item.ptyId, incarnationId: item.intent.incarnationId })) - ) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).toHaveBeenCalledTimes(12) - expect(listProcesses).toHaveBeenCalledTimes(12 / 4 + 1) - expect(terminated).toHaveLength(12) - }) - - it('costs one inventory and one confirmation for a single pending stop', async () => { - const { store } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, listProcesses } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(listProcesses).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/ssh/ssh-pending-pty-kill-replay.ts b/src/main/ssh/ssh-pending-pty-kill-replay.ts deleted file mode 100644 index 90688aa45d85..000000000000 --- a/src/main/ssh/ssh-pending-pty-kill-replay.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { - decideSshPendingPtyKill, - type SshPendingPtyKillEntry, - type SshPendingPtyKillObservation, - type SshPendingPtyKillRetirement -} from '../../shared/ssh-pending-pty-kill' - -export type SshPendingPtyKillReplayArgs = { - targetId: string - store: Store - provider: IPtyProvider - shouldContinue: () => boolean - now?: () => number -} - -/** Stops issued per inventory read. Every stop in a wave is dispatched concurrently, immediately - * after the read that fenced it, so no kill is ever aimed with evidence older than one round trip. */ -const REPLAY_WAVE_SIZE = 4 - -type HostInventory = Map - -/** Relay pty id -> the incarnation the host published for it, `undefined` when it published none. */ -async function readHostInventory(args: SshPendingPtyKillReplayArgs): Promise { - const processes = await args.provider.listProcesses() - const inventory: HostInventory = new Map() - for (const process of processes) { - inventory.set(toRelaySshPtyId(args.targetId, process.id), process.incarnationId) - } - return inventory -} - -/** Retires one order. - * - * Both tombstoning reasons are derived from a successful `pty.listProcesses`, never from an error - * predicate: `isPtyAlreadyGoneError` matches on message text (`/Session not found/i`), which a - * transport failure could wear, and a tombstone written on that would bury a live remote shell. - * A recycled id gets `expired`, not `terminated` — the client has lost its route to that lease, - * which is all that was observed; nothing says the shell it named ever died. */ -function retire( - args: SshPendingPtyKillReplayArgs, - relayPtyId: string, - reason: SshPendingPtyKillRetirement -): void { - args.store.clearSshRemotePtyKillIntent(args.targetId, relayPtyId) - if (reason === 'host-reports-absent' || reason === 'stop-confirmed') { - args.store.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') - } else if (reason === 'relay-id-recycled') { - // Why this must happen: the reattach one step later fences only on paneKey/tabId, never on - // incarnation. Leaving this lease reattachable hands the user's old pane to whatever process - // now holds the recycled id. `expired` alone no longer excludes it — that state also covers - // orphans the reattach is now meant to re-adopt — so the recycling is recorded explicitly. - args.store.markSshRemotePtyLease(args.targetId, relayPtyId, 'expired', { - relayIdRecycled: true - }) - } - console.log( - `[ssh-pending-kill] retired stop for ${args.targetId}/${relayPtyId} (${reason.replace(/-/g, ' ')})` - ) -} - -function observe(inventory: HostInventory, relayPtyId: string): SshPendingPtyKillObservation { - return { - hostListsPty: inventory.has(relayPtyId), - hostIncarnationId: inventory.get(relayPtyId) - } -} - -/** Applies every recorded order against one inventory, and returns the ones it says are safe to - * stop. Retirement side effects happen here, against that same fresh evidence. */ -function selectReplayTargets( - args: SshPendingPtyKillReplayArgs, - inventory: HostInventory, - now: number, - /** Orders already dispatched this pass. Skipped whole: the shutdown they were issued is what - * removes them from the next inventory, so re-deciding would retire them a second time. */ - attempted: ReadonlySet -): SshPendingPtyKillEntry[] { - const replayable: SshPendingPtyKillEntry[] = [] - for (const entry of args.store.getSshRemotePtyKillIntents(args.targetId, now)) { - if (attempted.has(entry.ptyId)) { - continue - } - const decision = decideSshPendingPtyKill(entry.intent, observe(inventory, entry.ptyId), now) - if (decision.action === 'retire') { - retire(args, entry.ptyId, decision.reason) - } else if (decision.action === 'defer') { - console.warn( - `[ssh-pending-kill] deferring stop for ${args.targetId}/${entry.ptyId}: ${decision.reason}` - ) - } else { - replayable.push(entry) - } - } - return replayable -} - -/** Issues one recorded stop. Returns false when it did not reach the host, in which case the order - * stays: a rejected RPC observes nothing, and the next handshake's inventory will answer. - * - * Re-checks the fence here rather than trusting the selection pass, so the identity proof and the - * irreversible call sit next to each other and cannot drift apart if this loop is ever reshaped. - * Current relays enforce that fence atomically at shutdown; older relays ignore the additive field - * and retain this inventory check as their mixed-version fallback. */ -async function deliverReplay( - args: SshPendingPtyKillReplayArgs, - entry: SshPendingPtyKillEntry, - inventory: HostInventory, - now: number -): Promise { - if ( - decideSshPendingPtyKill(entry.intent, observe(inventory, entry.ptyId), now).action !== 'replay' - ) { - return false - } - args.store.noteSshRemotePtyKillReplayAttempt(args.targetId, entry.ptyId) - try { - await args.provider.shutdown(toAppSshPtyId(args.targetId, entry.ptyId), { - immediate: true, - expectedIncarnationId: entry.intent.incarnationId - }) - return true - } catch (err) { - console.warn( - `[ssh-pending-kill] replay for ${args.targetId}/${entry.ptyId} is unverifiable: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return false - } -} - -/** Confirms delivered stops against one fresh inventory. - * - * Why re-list at all: the relay answers `pty.shutdown` for a PTY it never had with the same empty - * success, so a resolved RPC alone is not a death certificate. Absence from a live listing is. */ -async function confirmDelivered( - args: SshPendingPtyKillReplayArgs, - awaitingProof: readonly string[] -): Promise { - if (awaitingProof.length === 0) { - return - } - const inventory = await readHostInventory(args) - for (const relayPtyId of awaitingProof) { - if (!inventory.has(relayPtyId)) { - retire(args, relayPtyId, 'stop-confirmed') - } else { - console.warn( - `[ssh-pending-kill] ${args.targetId}/${relayPtyId} is still live after a replayed stop` - ) - } - } -} - -/** Replays every stop this client could not deliver to `targetId`, now that it is reachable again. - * - * Runs before reattach so a PTY that dies here is never re-adopted as a live pane. Costs nothing - * when nothing is pending. When something is, it re-reads the inventory once per wave rather than - * once per batch: the fence and the stops it authorises are then never more than one round trip - * apart, preserving the safest available behavior against older relays that ignore the shutdown - * incarnation field. - * - * Never throws. It is best-effort work on the connect path, and `establish()` treats a throw here - * as a failed connection. */ -export async function replayPendingSshPtyKills(args: SshPendingPtyKillReplayArgs): Promise { - const now = args.now ?? Date.now - try { - // Inside the guard with everything else: these touch persistence, and a disk hiccup must not - // turn best-effort cleanup into a failed SSH connection. - args.store.pruneExpiredSshRemotePtyKillIntents(args.targetId, now()) - if (args.store.getSshRemotePtyKillIntents(args.targetId, now()).length === 0) { - return - } - const attempted = new Set() - const awaitingProof: string[] = [] - while (args.shouldContinue()) { - // No inventory means no fence, and an unfenced replay can kill a shell nobody asked to close. - const inventory = await readHostInventory(args) - const selected = selectReplayTargets(args, inventory, now(), attempted) - const wave = selected.slice(0, REPLAY_WAVE_SIZE) - if (wave.length === 0) { - break - } - for (const entry of wave) { - attempted.add(entry.ptyId) - } - const delivered = await Promise.all( - wave.map(async (entry) => - (await deliverReplay(args, entry, inventory, now())) ? entry.ptyId : null - ) - ) - for (const relayPtyId of delivered) { - if (relayPtyId !== null) { - awaitingProof.push(relayPtyId) - } - } - if (selected.length <= REPLAY_WAVE_SIZE) { - // This wave took everything the inventory offered, so another read would only confirm that. - break - } - } - if (args.shouldContinue()) { - await confirmDelivered(args, awaitingProof) - } - } catch (err) { - console.warn( - `[ssh-pending-kill] replay pass on ${args.targetId} stopped early; stops stay pending: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } -} diff --git a/src/main/ssh/ssh-plain-ssh-session.ts b/src/main/ssh/ssh-plain-ssh-session.ts deleted file mode 100644 index 74e7b19644ec..000000000000 --- a/src/main/ssh/ssh-plain-ssh-session.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Registers the plain SSH providers for a target whose runtime ladder ended at rung D, feeds - * their output through the same SSH output intake the relay uses, and removes them again. - */ -import type { SshPlainSshMode, SshRemotePlatform } from '../../shared/ssh-types' -import { isCurrentPtyExit } from '../ipc/pty' -import { - acceptSshPtyOutputData, - acceptSshPtyOutputExit, - allocateSshPtyProviderGeneration, - closeSshPtyOutputGeneration -} from '../ipc/ssh-pty-output-intake-registry' -import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' -import type { SshPtyExitCallback } from '../providers/ssh-pty-provider-contract' -import { SshSftpFilesystemProvider } from '../providers/ssh-sftp-filesystem-provider' -import type { SshConnection } from './ssh-connection' -import { registerSshHostProviders, retireSshHostProviders } from './ssh-host-provider-set' -import { - clearSshPlainSshMode, - plainSshModeFromRuntimeUnavailable, - setSshPlainSshMode -} from './ssh-plain-ssh-mode' -import type { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' - -type PlainSshExitPayload = Parameters[0] - -export class SshPlainSshModeSession { - private left = false - - private constructor( - readonly targetId: string, - readonly mode: SshPlainSshMode, - private readonly ptyProvider: SshPlainShellPtyProvider, - private readonly fsProvider: SshSftpFilesystemProvider, - /** OS the runtime ladder detected before it gave up; agent launches quote for it. */ - readonly remotePlatform: SshRemotePlatform | undefined - ) {} - - static enter(args: { - targetId: string - connection: SshConnection - error: RemoteRuntimeUnavailableError - onExitAccepted: (payload: PlainSshExitPayload) => void - }): SshPlainSshModeSession { - const { targetId, connection } = args - const mode = plainSshModeFromRuntimeUnavailable(args.error) - const generation = allocateSshPtyProviderGeneration() - const windowsHost = args.error.data.host.platform === 'win32' - const ptyProvider = new SshPlainShellPtyProvider( - targetId, - (pty) => connection.shell(pty), - mode, - !windowsHost, - generation - ) - const fsProvider = new SshSftpFilesystemProvider( - targetId, - (options) => connection.sftp(options), - mode, - windowsHost - ) - const detected = args.error.data.host.platform - const session = new SshPlainSshModeSession( - targetId, - mode, - ptyProvider, - fsProvider, - detected === 'linux' || detected === 'darwin' || detected === 'win32' ? detected : undefined - ) - ptyProvider.onData((payload) => { - if (session.left) { - return - } - void acceptSshPtyOutputData({ - id: payload.id, - data: payload.data, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation, - rawLength: payload.data.length, - transformed: false - }).catch(() => {}) - }) - ptyProvider.onExit((payload) => { - if (session.left || !isCurrentPtyExit(payload)) { - return - } - void acceptSshPtyOutputExit({ - id: payload.id, - code: payload.code, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation - }) - .then(() => args.onExitAccepted(payload)) - .catch(() => {}) - }) - setSshPlainSshMode(targetId, mode) - registerSshHostProviders(targetId, { pty: ptyProvider, fs: fsProvider, git: null }) - return session - } - - probeTransport(timeoutMs: number): Promise { - return this.left ? Promise.resolve(false) : this.fsProvider.probeTransport(timeoutMs) - } - - /** Transport gone or session torn down: open shells stay unverifiable, never exited. */ - leave(): void { - if (this.left) { - return - } - this.left = true - clearSshPlainSshMode(this.targetId) - closeSshPtyOutputGeneration(this.ptyProvider.providerGeneration, 'connection_lost') - retireSshHostProviders(this.targetId, { pty: this.ptyProvider, fs: this.fsProvider }) - } -} diff --git a/src/main/ssh/ssh-port-forward-provider.ts b/src/main/ssh/ssh-port-forward-provider.ts index ba17d434dc3c..51c134047498 100644 --- a/src/main/ssh/ssh-port-forward-provider.ts +++ b/src/main/ssh/ssh-port-forward-provider.ts @@ -20,6 +20,8 @@ export type StartedPortForward = { entry: PortForwardEntry close: () => Promise dispose: () => void + /** False once `conn` no longer carries the transport this forward tunnels through. */ + isLive?: (conn: SshConnection) => boolean } export type SshPortForwardProvider = { diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index 147ba2f08d21..f4af3a36e6e6 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -412,6 +412,22 @@ describe('SshPortForwardManager', () => { expect(manager.listForwards('conn-2')).toHaveLength(1) }) + it('closes only the ssh2 forwards bound to a client the connection replaced', async () => { + const conn = createMockConn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements the getClient/usesSystemSshTransport surface the ssh2 provider reads. + const sshConn = conn as never + await manager.addForward('conn-1', sshConn, 3000, 'localhost', 8080) + await manager.addForward('conn-2', sshConn, 3002, 'localhost', 8082) + + await manager.closeStaleForwards('conn-1', sshConn) + expect(manager.listForwards('conn-1')).toHaveLength(1) + + conn.getClient.mockReturnValue({ forwardOut: vi.fn() }) + await manager.closeStaleForwards('conn-1', sshConn) + expect(manager.listForwards('conn-1')).toHaveLength(0) + expect(manager.listForwards('conn-2')).toHaveLength(1) + }) + it('dispose removes all forwards', async () => { const conn = createMockConn() await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) diff --git a/src/main/ssh/ssh-port-forward.ts b/src/main/ssh/ssh-port-forward.ts index bc151eaab1e0..4c7b72078253 100644 --- a/src/main/ssh/ssh-port-forward.ts +++ b/src/main/ssh/ssh-port-forward.ts @@ -182,6 +182,15 @@ export class SshPortForwardManager { await Promise.all(toRemove.map((id) => this.removeForwardAsync(id))) } + /** Closes this connection's forwards that tunnel through a transport `conn` replaced. */ + async closeStaleForwards(connectionId: string, conn: SshConnection): Promise { + const stale = [...this.forwards.entries()] + .filter(([, forward]) => forward.entry.connectionId === connectionId) + .filter(([, forward]) => forward.isLive?.(conn) === false) + .map(([id]) => id) + await Promise.all(stale.map((id) => this.removeForwardAsync(id))) + } + dispose(): void { const ids = [...this.forwards.keys()] for (const id of ids) { diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts deleted file mode 100644 index f903d71acf0f..000000000000 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - PortScanner, - SSH_PORT_SCAN_BASE_INTERVAL_MS, - SSH_PORT_SCAN_MAX_INTERVAL_MS, - type PortScannerWindowVisibility -} from './ssh-port-scanner' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { DetectedPort } from '../../shared/ssh-types' - -type VisibilityHarness = { - visibility: PortScannerWindowVisibility - setVisible: (visible: boolean) => void - listenerCount: () => number -} - -function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness { - let visible = initiallyVisible - const listeners = new Set<() => void>() - return { - visibility: { - isWindowVisible: () => visible, - onWindowBecameVisible: (listener) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } - } - }, - setVisible: (next) => { - const wasVisible = visible - visible = next - if (!wasVisible && next) { - for (const listener of Array.from(listeners)) { - listener() - } - } - }, - listenerCount: () => listeners.size - } -} - -function port(portNumber: number): DetectedPort { - return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' } -} - -function createMux(ports: () => DetectedPort[]): { - mux: SshChannelMultiplexer - request: ReturnType -} { - const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' })) - return { mux: { request } as unknown as SshChannelMultiplexer, request } -} - -const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS -const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS - -describe('PortScanner', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => { - const harness = createVisibilityHarness(false) - const { mux, request } = createMux(() => [port(3000)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - - await vi.advanceTimersByTimeAsync(10 * 60_000) - expect(request).not.toHaveBeenCalled() - - harness.setVisible(true) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - - scanner.dispose() - }) - - it('polls at the base cadence while visible when results keep changing', async () => { - const harness = createVisibilityHarness(true) - let next = 3000 - const { mux, request } = createMux(() => [port(next++)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - - // 5 simulated base intervals -> 5 more scans (no backoff while changing). - await vi.advanceTimersByTimeAsync(5 * BASE) - expect(request).toHaveBeenCalledTimes(6) - - scanner.dispose() - }) - - it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => { - const harness = createVisibilityHarness(true) - let ports = [port(3000)] - const { mux, request } = createMux(() => ports) - const onChanged = vi.fn() - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, onChanged) - - // t=0: first scan is a change (empty -> {3000}) and stays at base. - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - expect(onChanged).toHaveBeenCalledTimes(1) - - // t=12s: unchanged -> next wait doubles to 24s. - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - // t=24s: nothing (waiting until t=36s). - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - // t=36s: unchanged -> next wait caps at 30s (not 48s). - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(3) - - // t=65.999s: still waiting. - await vi.advanceTimersByTimeAsync(MAX - 1) - expect(request).toHaveBeenCalledTimes(3) - - // t=66s: unchanged -> next wait remains capped at 30s. - await vi.advanceTimersByTimeAsync(1) - expect(request).toHaveBeenCalledTimes(4) - - await vi.advanceTimersByTimeAsync(MAX - 1) - expect(request).toHaveBeenCalledTimes(4) - await vi.advanceTimersByTimeAsync(1) - expect(request).toHaveBeenCalledTimes(5) - expect(onChanged).toHaveBeenCalledTimes(1) - - // A changed result resets the cadence back to base. - ports = [port(3000), port(4000)] - await vi.advanceTimersByTimeAsync(MAX) - expect(request).toHaveBeenCalledTimes(6) - expect(onChanged).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(7) - expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000]) - - scanner.dispose() - }) - - it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => { - const harness = createVisibilityHarness(true) - const { mux, request } = createMux(() => [port(3000)]) - const scanner = new PortScanner(harness.visibility) - const onChanged = vi.fn() - scanner.startScanning('t1', mux, onChanged) - - // Back off: scans at t=0, t=12s, t=36s -> interval now 48s. - await vi.advanceTimersByTimeAsync(3 * BASE) - expect(request).toHaveBeenCalledTimes(3) - - // Reconnect paths call startScanning again: immediate scan, base cadence. - scanner.startScanning('t1', mux, onChanged) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(4) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(5) - - scanner.dispose() - }) - - it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => { - const harness = createVisibilityHarness(true) - let next = 3000 - const { mux, request } = createMux(() => [port(next++)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - harness.setVisible(false) - await vi.advanceTimersByTimeAsync(30 * 60_000) - expect(request).toHaveBeenCalledTimes(2) - - harness.setVisible(true) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(3) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(4) - - scanner.dispose() - }) - - it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => { - const harness = createVisibilityHarness(true) - let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null - const request = vi.fn( - () => - new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => { - resolveFirst = resolve - }) - ) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn()) - expect(request).toHaveBeenCalledTimes(1) - - // The chain waits for the in-flight request instead of stacking more. - await vi.advanceTimersByTimeAsync(10 * BASE) - expect(request).toHaveBeenCalledTimes(1) - - resolveFirst!({ ports: [port(3000)], platform: 'linux' }) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - scanner.dispose() - }) - - it('stopScanning halts polling and detaches the visibility listener', async () => { - const harness = createVisibilityHarness(true) - const { mux, request } = createMux(() => [port(3000)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - expect(harness.listenerCount()).toBe(1) - - scanner.stopScanning('t1') - expect(harness.listenerCount()).toBe(0) - await vi.advanceTimersByTimeAsync(10 * 60_000) - harness.setVisible(false) - harness.setVisible(true) - await vi.advanceTimersByTimeAsync(10 * 60_000) - expect(request).toHaveBeenCalledTimes(1) - expect(scanner.getDetectedPorts('t1')).toEqual([]) - }) - - it('aborts in-flight requests before stopping or replacing a target scan', async () => { - const harness = createVisibilityHarness(true) - const signals: AbortSignal[] = [] - const request = vi.fn( - (_method: string, _params?: Record, options?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - const signal = options?.signal - if (!signal) { - reject(new Error('missing request signal')) - return - } - signals.push(signal) - signal.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - ) - const mux = { request } as unknown as SshChannelMultiplexer - const scanner = new PortScanner(harness.visibility) - - scanner.startScanning('t1', mux, vi.fn()) - expect(signals).toHaveLength(1) - expect(signals[0].aborted).toBe(false) - - scanner.startScanning('t1', mux, vi.fn()) - expect(signals).toHaveLength(2) - expect(signals[0].aborted).toBe(true) - expect(signals[1].aborted).toBe(false) - - scanner.stopScanning('t1') - expect(signals[1].aborted).toBe(true) - await vi.advanceTimersByTimeAsync(10 * BASE) - expect(request).toHaveBeenCalledTimes(2) - expect(harness.listenerCount()).toBe(0) - }) - - it('keeps targets independent: stopping one host leaves the other scanning', async () => { - const harness = createVisibilityHarness(true) - let nextA = 3000 - let nextB = 4000 - const a = createMux(() => [port(nextA++)]) - const b = createMux(() => [port(nextB++)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('host-a', a.mux, vi.fn()) - scanner.startScanning('host-b', b.mux, vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(a.request).toHaveBeenCalledTimes(1) - expect(b.request).toHaveBeenCalledTimes(1) - - scanner.stopScanning('host-a') - await vi.advanceTimersByTimeAsync(BASE) - expect(a.request).toHaveBeenCalledTimes(1) - expect(b.request).toHaveBeenCalledTimes(2) - - scanner.dispose() - await vi.advanceTimersByTimeAsync(10 * BASE) - expect(b.request).toHaveBeenCalledTimes(2) - expect(harness.listenerCount()).toBe(0) - }) -}) diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts deleted file mode 100644 index 2dbabff597a2..000000000000 --- a/src/main/ssh/ssh-port-scanner.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { DetectedPort } from '../../shared/ssh-types' - -// Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote -// CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the -// old 3s while keeping new-port detection well under the 30s workspace-scanner -// cadence users already accept. -export const SSH_PORT_SCAN_BASE_INTERVAL_MS = 12_000 -// Why: idle backoff cap — an unchanged port set doubles the interval up to -// this bound, so a quiet remote costs two scans per minute instead of twenty -// without exceeding the existing workspace-port scanner's visible cadence. -export const SSH_PORT_SCAN_MAX_INTERVAL_MS = 30_000 - -export type PortScannerWindowVisibility = { - isWindowVisible: () => boolean - // Fires when a hidden/minimized window is shown again; returns unsubscribe. - onWindowBecameVisible: (listener: () => void) => () => void -} - -type ScanHandle = { - timer: ReturnType | null - requestAbortController: AbortController | null - intervalMs: number - // Why: while the window is hidden the scan chain is parked outright — no - // timer wakeups, no remote requests — and the visibility listener resumes - // it with an immediate scan so ports opened while hidden surface at once. - parkedWhileHidden: boolean - unsubscribeVisibility: () => void - // Why: keyed by "host:port" (not just port) so that host-distinct listeners - // on the same port (e.g. 127.0.0.1:3000 + 0.0.0.0:3000) are tracked separately. - previousPorts: Map - // Why: ports detected on the first scan are pre-existing services (sshd, system - // daemons) that the user didn't just start. VS Code calls these "initialCandidates" - // and excludes them from auto-forward suggestions (Phase 3). - initialPorts: Set | null -} - -export class PortScanner { - private handles = new Map() - - constructor(private visibility: PortScannerWindowVisibility) {} - - startScanning( - targetId: string, - mux: SshChannelMultiplexer, - onChanged: (targetId: string, ports: DetectedPort[], platform: string) => void - ): void { - this.stopScanning(targetId) - - const handle: ScanHandle = { - timer: null, - requestAbortController: null, - intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, - parkedWhileHidden: false, - unsubscribeVisibility: () => {}, - previousPorts: new Map(), - initialPorts: null - } - const isCurrent = (): boolean => this.handles.get(targetId) === handle - - // Why: guard against overlapping scans. The timer chain only reschedules - // after a poll completes, but the visibility-resume path can race a poll - // still in flight on a slow remote. - let polling = false - const poll = async (): Promise => { - if (polling) { - return - } - polling = true - const requestAbortController = new AbortController() - handle.requestAbortController = requestAbortController - try { - const result = (await mux.request('ports.detect', undefined, { - signal: requestAbortController.signal - })) as { - ports: DetectedPort[] - platform: string - } - - if (!isCurrent()) { - return - } - - const currentPorts = new Map() - for (const p of result.ports) { - currentPorts.set(`${p.host}:${p.port}`, p) - } - - if (handle.initialPorts === null) { - handle.initialPorts = new Set(currentPorts.keys()) - } - - if (!portsEqual(handle.previousPorts, currentPorts)) { - handle.previousPorts = currentPorts - handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS - onChanged(targetId, result.ports, result.platform) - } else { - handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS) - } - } catch { - // Relay disconnected or request timed out — retry on next interval - } finally { - if (handle.requestAbortController === requestAbortController) { - handle.requestAbortController = null - } - polling = false - } - } - - const tick = async (): Promise => { - handle.timer = null - if (!isCurrent()) { - return - } - if (!this.visibility.isWindowVisible()) { - handle.parkedWhileHidden = true - return - } - await poll() - if (!isCurrent()) { - return - } - handle.timer = setTimeout(() => void tick(), handle.intervalMs) - } - - handle.unsubscribeVisibility = this.visibility.onWindowBecameVisible(() => { - if (!isCurrent() || !handle.parkedWhileHidden) { - return - } - handle.parkedWhileHidden = false - void tick() - }) - - this.handles.set(targetId, handle) - void tick() - } - - getDetectedPorts(targetId: string): DetectedPort[] { - const handle = this.handles.get(targetId) - if (!handle) { - return [] - } - return Array.from(handle.previousPorts.values()) - } - - stopScanning(targetId: string): void { - const handle = this.handles.get(targetId) - if (!handle) { - return - } - if (handle.timer) { - clearTimeout(handle.timer) - } - handle.requestAbortController?.abort() - handle.requestAbortController = null - handle.unsubscribeVisibility() - this.handles.delete(targetId) - } - - dispose(): void { - for (const [targetId] of this.handles) { - this.stopScanning(targetId) - } - } -} - -function portsEqual(a: Map, b: Map): boolean { - if (a.size !== b.size) { - return false - } - for (const [key, entryA] of a) { - const entryB = b.get(key) - if (!entryB) { - return false - } - if (entryA.pid !== entryB.pid || entryA.processName !== entryB.processName) { - return false - } - } - return true -} diff --git a/src/main/ssh/ssh-pty-consumer-recovery.test.ts b/src/main/ssh/ssh-pty-consumer-recovery.test.ts deleted file mode 100644 index d77e8c3c8dc6..000000000000 --- a/src/main/ssh/ssh-pty-consumer-recovery.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' -import { - claimSshPtyConsumerRecovery, - detachSshPtyConsumerRecovery, - rememberSshPtyConsumerRecovery -} from './ssh-pty-consumer-recovery' - -describe('SSH PTY consumer recovery', () => { - it('says out loud when it mints a new identity, because the sweep goes quiet after', () => { - // The id the host attests on every PTY it holds for us. Minting a new one is fail-safe — it - // can only under-sweep — but it makes the reaper silently stop working, so it must be visible. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const store = { - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn() - } as unknown as Store - - claimSshPtyConsumerRecovery('mint-logs-the-loss', store) - - expect(warn).toHaveBeenCalledWith(expect.stringContaining('minting a new consumer identity')) - warn.mockRestore() - }) - - it('keeps a detached identity when a concurrent open finishes late', async () => { - const targetId = 'remember-detach-race' - const store = { - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn() - } as unknown as Store - const claimed = claimSshPtyConsumerRecovery(targetId, store) - let finishOpen!: (owner: SshPtyConsumerOwnerState) => void - const opened = new Promise((resolve) => { - finishOpen = resolve - }) - const remembering = opened.then((owner) => - rememberSshPtyConsumerRecovery({ - targetId, - clientInstanceId: claimed.clientInstanceId, - serverBuildId: 'relay-build', - owner, - store - }) - ) - - detachSshPtyConsumerRecovery(targetId, claimed.clientInstanceId) - finishOpen({ - mode: 'negotiated', - clientInstanceId: claimed.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'late-owner' - }) - await remembering - - expect(store.upsertSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(claimSshPtyConsumerRecovery(targetId, store)).toBe(claimed) - }) -}) diff --git a/src/main/ssh/ssh-pty-consumer-recovery.ts b/src/main/ssh/ssh-pty-consumer-recovery.ts deleted file mode 100644 index 727f293b5728..000000000000 --- a/src/main/ssh/ssh-pty-consumer-recovery.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { SshPtyConsumerRecovery as PersistedRecovery } from '../../shared/ssh-types' -import type { SshPtyAcceptedSourceCheckpoint } from '../ipc/ssh-pty-output-source-obligations' -import type { SshPtyOutputMigrationResult } from '../ipc/ssh-pty-output-model-migration' -import type { Store } from '../persistence' -import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' - -export type SshPtyConsumerRecoveryState = { - clientInstanceId: string - detached: boolean - serverBuildId?: string - owner?: SshPtyConsumerOwnerState - checkpointsByAppPtyId: Map - modelMigrationsByAppPtyId: Map> -} - -const recoveryByTarget = new Map() - -function ownerFromPersisted(record: PersistedRecovery): SshPtyConsumerOwnerState { - return { - mode: 'negotiated', - clientInstanceId: record.clientInstanceId, - clientGeneration: record.clientGeneration, - ownerGeneration: record.ownerGeneration, - ownerLease: record.ownerLease, - ...(record.outputFlowControl ? { outputFlowControl: record.outputFlowControl } : {}) - } -} - -export function claimSshPtyConsumerRecovery( - targetId: string, - store: Store -): SshPtyConsumerRecoveryState { - const current = recoveryByTarget.get(targetId) - if (current?.detached) { - current.detached = false - return current - } - const persisted = current ? null : store.getSshPtyConsumerRecovery(targetId) - if (!persisted) { - // Deliberately not stabilized: this id is also the consumer session's ownership identity, and - // reusing it without the generations the same dropped record carried would replay a stale - // owner generation at the relay. The cost is visible instead of silent — every relay PTY the - // host still attributes to the previous identity becomes permanently unsweepable (#9819). - console.warn( - `[ssh-pty-consumer] no recovery record for ${targetId}; minting a new consumer identity. Relay PTYs the host attributes to this client's previous identity can no longer be swept.` - ) - } - const created: SshPtyConsumerRecoveryState = { - clientInstanceId: persisted?.clientInstanceId ?? randomUUID(), - detached: false, - ...(persisted - ? { serverBuildId: persisted.serverBuildId, owner: ownerFromPersisted(persisted) } - : {}), - checkpointsByAppPtyId: new Map(), - modelMigrationsByAppPtyId: new Map() - } - recoveryByTarget.set(targetId, created) - return created -} - -export function getSshPtyConsumerRecovery( - targetId: string -): SshPtyConsumerRecoveryState | undefined { - return recoveryByTarget.get(targetId) -} - -// Why async: the in-memory update lands synchronously (before the first await) so no caller can -// observe a torn state; only the awaited durability barrier is deferred. -export async function rememberSshPtyConsumerRecovery(args: { - targetId: string - clientInstanceId: string - serverBuildId: string - owner: SshPtyConsumerOwnerState - store: Store -}): Promise { - const current = recoveryByTarget.get(args.targetId) - if (current?.clientInstanceId !== args.clientInstanceId || current.detached) { - return - } - current.serverBuildId = args.serverBuildId - current.owner = args.owner - await args.store.upsertSshPtyConsumerRecovery({ - targetId: args.targetId, - clientInstanceId: args.clientInstanceId, - serverBuildId: args.serverBuildId, - clientGeneration: args.owner.clientGeneration, - ownerGeneration: args.owner.ownerGeneration, - ownerLease: args.owner.ownerLease, - ...(args.owner.outputFlowControl ? { outputFlowControl: args.owner.outputFlowControl } : {}) - }) -} - -export async function removeSshPtyConsumerOwnerRecovery( - targetId: string, - clientInstanceId: string, - store: Store -): Promise { - await store.removeSshPtyConsumerRecovery(targetId, clientInstanceId) -} - -export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: string): void { - const current = recoveryByTarget.get(targetId) - if (current?.clientInstanceId === clientInstanceId) { - current.detached = true - } -} - -export async function forgetSshPtyConsumerRecovery( - targetId: string, - clientInstanceId: string, - store: Store -): Promise { - const current = recoveryByTarget.get(targetId) - if (current?.clientInstanceId === clientInstanceId) { - recoveryByTarget.delete(targetId) - } - await removeSshPtyConsumerOwnerRecovery(targetId, clientInstanceId, store) -} diff --git a/src/main/ssh/ssh-pty-frame-rejection.ts b/src/main/ssh/ssh-pty-frame-rejection.ts deleted file mode 100644 index 07dcf3827cdb..000000000000 --- a/src/main/ssh/ssh-pty-frame-rejection.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' -import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' - -type SshPtyDataPayload = Parameters[0] - -const SSH_PTY_FRAME_REJECTION_LOG_KEY_LIMIT = 12 - -export type SshPtyFrameRejectionReason = - | 'source-missing' - | 'source-malformed' - | 'client-generation-stale' - | 'owner-generation-stale' - | 'client-and-owner-generation-stale' - | 'source-range-invalid' - -export type SshPtyFrameRejection = Readonly<{ - reason: SshPtyFrameRejectionReason - action: 'quarantine-legacy-frame' | 'retire-and-reattach-delivery' -}> - -// Why a source-less frame is quarantined rather than reattached: it is the signature of legacy -// output already in flight when the flow-control grant landed, not of a broken delivery. The relay's -// own admission stops publishing those once the grant is live, and source recovery replays from -// sourceStartSu, so dropping them loses nothing. Reattaching instead would restart the PTY on every -// openClient handshake. -export function classifySshPtyFrameRejection( - payload: SshPtyDataPayload, - owner: SshPtyConsumerOwnerState | null -): SshPtyFrameRejection | null { - if (payload.sourceMalformed) { - return Object.freeze({ - reason: 'source-malformed', - action: 'retire-and-reattach-delivery' - }) - } - const source = payload.source - if (!source) { - return owner?.outputFlowControl - ? Object.freeze({ - reason: 'source-missing', - action: 'quarantine-legacy-frame' - }) - : null - } - if (!owner?.outputFlowControl) { - return payload.sourceRejected - ? Object.freeze({ - reason: 'source-range-invalid', - action: 'retire-and-reattach-delivery' - }) - : null - } - const staleClient = source.clientGeneration !== owner.clientGeneration - const staleOwner = source.ownerGeneration !== owner.ownerGeneration - if (staleClient || staleOwner) { - return Object.freeze({ - reason: - staleClient && staleOwner - ? 'client-and-owner-generation-stale' - : staleClient - ? 'client-generation-stale' - : 'owner-generation-stale', - action: 'retire-and-reattach-delivery' - }) - } - if (payload.sourceRejected) { - return Object.freeze({ - reason: 'source-range-invalid', - action: 'retire-and-reattach-delivery' - }) - } - return null -} - -/** - * Why bounded: a quarantined frame carries no delivery token to retire, so nothing dedupes it — a - * relay that keeps publishing legacy output to a flow-control owner would log once per frame and - * bury every other diagnostic. One line per PTY and reason per generation names the fault; the - * generation is itself the counter for how often it recurs. - */ -export class SshPtyFrameRejectionLog { - private generation: number | null = null - private readonly loggedKeys = new Set() - - record( - payload: SshPtyDataPayload, - owner: SshPtyConsumerOwnerState | null, - rejection: SshPtyFrameRejection - ): void { - if (this.generation !== payload.providerGeneration) { - this.generation = payload.providerGeneration - this.loggedKeys.clear() - } - const key = `${payload.id}\0${rejection.reason}` - if (this.loggedKeys.has(key) || this.loggedKeys.size >= SSH_PTY_FRAME_REJECTION_LOG_KEY_LIMIT) { - return - } - this.loggedKeys.add(key) - console.warn('[ssh-relay-session] Rejected PTY delivery', { - ptyId: payload.id, - providerGeneration: payload.providerGeneration, - expectedClientGeneration: owner?.clientGeneration ?? null, - expectedOwnerGeneration: owner?.ownerGeneration ?? null, - offeredClientGeneration: payload.source?.clientGeneration ?? null, - offeredOwnerGeneration: payload.source?.ownerGeneration ?? null, - sourceState: rejection.reason, - recoveryAction: rejection.action - }) - } - - clear(): void { - this.generation = null - this.loggedKeys.clear() - } -} diff --git a/src/main/ssh/ssh-pty-recovery-retention-budget.test.ts b/src/main/ssh/ssh-pty-recovery-retention-budget.test.ts deleted file mode 100644 index 47219e617800..000000000000 --- a/src/main/ssh/ssh-pty-recovery-retention-budget.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SshPtyRecoveryRetentionBudget } from './ssh-pty-recovery-retention-budget' - -describe('SshPtyRecoveryRetentionBudget', () => { - it('bounds fragmented recovery by per-PTY and session source, charged bytes, and frames', () => { - const budget = new SshPtyRecoveryRetentionBudget({ - perPtySourceSu: 4, - perPtyBytes: 300, - perPtyFrames: 2, - sessionSourceSu: 6, - sessionBytes: 500, - sessionFrames: 3 - }) - - expect(budget.tryRetain('pty-1', 'aa', 2)).toBe(true) - expect(budget.tryRetain('pty-1', 'bb', 2)).toBe(true) - expect(budget.tryRetain('pty-1', 'c', 0)).toBe(false) - expect(budget.tryRetain('pty-2', 'cc', 2)).toBe(true) - expect(budget.snapshot()).toEqual({ sourceSu: 6, bytes: 396, frames: 3, ptys: 2 }) - expect(budget.tryRetain('pty-2', 'd', 1)).toBe(false) - - budget.release('pty-1') - - expect(budget.snapshot()).toEqual({ sourceSu: 2, bytes: 132, frames: 1, ptys: 1 }) - expect(budget.tryRetain('pty-2', 'dd', 2)).toBe(true) - }) - - it('charges UTF-16 storage plus one record before aggregate admission', () => { - const budget = new SshPtyRecoveryRetentionBudget({ - perPtySourceSu: 100, - perPtyBytes: 1_000, - perPtyFrames: 10, - sessionSourceSu: 100, - sessionBytes: 500, - sessionFrames: 10 - }) - - expect(budget.tryRetain('pty-1', '\u0000'.repeat(64), 1)).toBe(true) - expect(budget.snapshot().bytes).toBe(256) - expect(budget.tryRetain('pty-2', '\u0000'.repeat(64), 1)).toBe(false) - expect(budget.snapshot()).toEqual({ sourceSu: 1, bytes: 256, frames: 1, ptys: 1 }) - }) -}) diff --git a/src/main/ssh/ssh-pty-recovery-retention-budget.ts b/src/main/ssh/ssh-pty-recovery-retention-budget.ts deleted file mode 100644 index 873b56a87784..000000000000 --- a/src/main/ssh/ssh-pty-recovery-retention-budget.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' -import { chargedPtyRetainedStringBytes } from '../../shared/pty-retained-string-memory' - -export const SSH_PTY_RECOVERY_PER_PTY_MAX_SOURCE_SU = DEFAULT_PTY_SOURCE_WINDOW_SU -export const SSH_PTY_RECOVERY_PER_PTY_MAX_BYTES = 2 * 1024 * 1024 -export const SSH_PTY_RECOVERY_PER_PTY_MAX_FRAMES = 1_024 -export const SSH_PTY_RECOVERY_SESSION_MAX_SOURCE_SU = 50 * DEFAULT_PTY_SOURCE_WINDOW_SU -export const SSH_PTY_RECOVERY_SESSION_MAX_BYTES = 64 * 1024 * 1024 -export const SSH_PTY_RECOVERY_SESSION_MAX_FRAMES = 64 * 1_024 - -export type SshPtyRecoveryRetentionLimits = Readonly<{ - perPtySourceSu: number - perPtyBytes: number - perPtyFrames: number - sessionSourceSu: number - sessionBytes: number - sessionFrames: number -}> - -type RetainedPty = { - sourceSu: number - bytes: number - frames: number -} - -const DEFAULT_LIMITS: SshPtyRecoveryRetentionLimits = Object.freeze({ - perPtySourceSu: SSH_PTY_RECOVERY_PER_PTY_MAX_SOURCE_SU, - perPtyBytes: SSH_PTY_RECOVERY_PER_PTY_MAX_BYTES, - perPtyFrames: SSH_PTY_RECOVERY_PER_PTY_MAX_FRAMES, - sessionSourceSu: SSH_PTY_RECOVERY_SESSION_MAX_SOURCE_SU, - sessionBytes: SSH_PTY_RECOVERY_SESSION_MAX_BYTES, - sessionFrames: SSH_PTY_RECOVERY_SESSION_MAX_FRAMES -}) - -export class SshPtyRecoveryRetentionBudget { - private readonly retainedByPty = new Map() - private sourceSu = 0 - private bytes = 0 - private frames = 0 - - constructor(private readonly limits: SshPtyRecoveryRetentionLimits = DEFAULT_LIMITS) {} - - tryRetain(ptyId: string, data: string, sourceSu: number): boolean { - if (!Number.isSafeInteger(sourceSu) || sourceSu < 0) { - return false - } - const retained = this.retainedByPty.get(ptyId) ?? { sourceSu: 0, bytes: 0, frames: 0 } - const chargedBytes = chargedPtyRetainedStringBytes(data) - if ( - retained.sourceSu + sourceSu > this.limits.perPtySourceSu || - retained.bytes + chargedBytes > this.limits.perPtyBytes || - retained.frames + 1 > this.limits.perPtyFrames || - this.sourceSu + sourceSu > this.limits.sessionSourceSu || - this.bytes + chargedBytes > this.limits.sessionBytes || - this.frames + 1 > this.limits.sessionFrames - ) { - return false - } - retained.sourceSu += sourceSu - retained.bytes += chargedBytes - retained.frames++ - this.retainedByPty.set(ptyId, retained) - this.sourceSu += sourceSu - this.bytes += chargedBytes - this.frames++ - return true - } - - release(ptyId: string): void { - const retained = this.retainedByPty.get(ptyId) - if (!retained) { - return - } - this.retainedByPty.delete(ptyId) - this.sourceSu -= retained.sourceSu - this.bytes -= retained.bytes - this.frames -= retained.frames - } - - clear(): void { - this.retainedByPty.clear() - this.sourceSu = 0 - this.bytes = 0 - this.frames = 0 - } - - snapshot(): Readonly<{ sourceSu: number; bytes: number; frames: number; ptys: number }> { - return Object.freeze({ - sourceSu: this.sourceSu, - bytes: this.bytes, - frames: this.frames, - ptys: this.retainedByPty.size - }) - } -} diff --git a/src/main/ssh/ssh-pty-retired-source-deliveries.test.ts b/src/main/ssh/ssh-pty-retired-source-deliveries.test.ts deleted file mode 100644 index 377d545592b6..000000000000 --- a/src/main/ssh/ssh-pty-retired-source-deliveries.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SshPtyRetiredSourceDeliveries } from './ssh-pty-retired-source-deliveries' - -const source = (deliveryToken: string, relayPtyId = 'pty-1') => ({ - relayPtyId, - deliveryToken, - clientGeneration: 2, - ownerGeneration: 3 -}) - -describe('SshPtyRetiredSourceDeliveries', () => { - it('retains only the latest canceled token for each ordered PTY stream', () => { - const retired = new SshPtyRetiredSourceDeliveries() - - for (let token = 0; token < 10_000; token++) { - retired.retire(1, source(`token-${token}`)) - } - - expect(retired.size).toBe(1) - expect(retired.has(1, source('token-9999'))).toBe(true) - expect(retired.has(1, source('token-9998'))).toBe(false) - }) - - it('retires state at the next activation or PTY exit boundary', () => { - const retired = new SshPtyRetiredSourceDeliveries() - retired.retire(1, source('old-token')) - retired.retire(1, source('other-token', 'pty-2')) - - retired.activate('pty-1') - - expect(retired.has(1, source('old-token'))).toBe(false) - expect(retired.has(1, source('other-token', 'pty-2'))).toBe(true) - expect(retired.size).toBe(1) - }) -}) diff --git a/src/main/ssh/ssh-pty-retired-source-deliveries.ts b/src/main/ssh/ssh-pty-retired-source-deliveries.ts deleted file mode 100644 index 8caca4f889fa..000000000000 --- a/src/main/ssh/ssh-pty-retired-source-deliveries.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { SshPtySourceFrame } from '../providers/ssh-pty-source-frame' - -type SourceDeliveryIdentity = Pick< - SshPtySourceFrame, - 'relayPtyId' | 'deliveryToken' | 'clientGeneration' | 'ownerGeneration' -> - -export class SshPtyRetiredSourceDeliveries { - private readonly keyByRelayPtyId = new Map() - - has(providerGeneration: number, source: SourceDeliveryIdentity): boolean { - return ( - this.keyByRelayPtyId.get(source.relayPtyId) === sourceDeliveryKey(providerGeneration, source) - ) - } - - retire(providerGeneration: number, source: SourceDeliveryIdentity): void { - this.keyByRelayPtyId.set(source.relayPtyId, sourceDeliveryKey(providerGeneration, source)) - } - - activate(relayPtyId: string): void { - this.keyByRelayPtyId.delete(relayPtyId) - } - - clear(): void { - this.keyByRelayPtyId.clear() - } - - get size(): number { - return this.keyByRelayPtyId.size - } -} - -function sourceDeliveryKey(providerGeneration: number, source: SourceDeliveryIdentity): string { - return `${providerGeneration}\0${source.clientGeneration}\0${source.ownerGeneration}\0${source.deliveryToken}` -} diff --git a/src/main/ssh/ssh-pty-targeted-reattach-queue.ts b/src/main/ssh/ssh-pty-targeted-reattach-queue.ts deleted file mode 100644 index 23329abf5875..000000000000 --- a/src/main/ssh/ssh-pty-targeted-reattach-queue.ts +++ /dev/null @@ -1,57 +0,0 @@ -type TargetedReattach = Readonly<{ start: () => void }> - -// Why bounded: every rejected frame asks for its own attach round trip, so a relay that starts -// rejecting across many PTYs at once fans out one concurrent reattach per PTY. The bulk reconnect -// path caps the identical work, and each attach also costs a lease read and a lease write. -export class SshPtyTargetedReattachQueue { - private readonly running = new Map() - private readonly waiting: TargetedReattach[] = [] - private active = 0 - - constructor(private readonly maxConcurrency: number) {} - - has(key: string): boolean { - return this.running.has(key) - } - - run(key: string, task: () => Promise): Promise { - return new Promise((resolve, reject) => { - const entry: TargetedReattach = { - start: () => { - this.active++ - task().then( - (recovered) => { - this.settle(key, entry) - resolve(recovered) - }, - (error: unknown) => { - this.settle(key, entry) - reject(error instanceof Error ? error : new Error(String(error))) - } - ) - } - } - this.running.set(key, entry) - if (this.active < this.maxConcurrency) { - entry.start() - return - } - this.waiting.push(entry) - }) - } - - // Why queued entries are dropped rather than started: each one is keyed to the provider generation - // the teardown just ended, so running it would attach onto a mux that is already gone. - clear(): void { - this.waiting.length = 0 - this.running.clear() - } - - private settle(key: string, entry: TargetedReattach): void { - if (this.running.get(key) === entry) { - this.running.delete(key) - } - this.active-- - this.waiting.shift()?.start() - } -} diff --git a/src/main/ssh/ssh-relay-build-toolchain.test.ts b/src/main/ssh/ssh-relay-build-toolchain.test.ts deleted file mode 100644 index 6fcc4f5321d3..000000000000 --- a/src/main/ssh/ssh-relay-build-toolchain.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildToolchainProbeCommand, - parseBuildToolchainProbe, - formatMissingToolchainError, - formatNodeHeadersDownloadError, - formatSkippedNodePtyWarning, - isNodeHeadersDownloadFailure, - shouldProbeBuildToolchainAfterNativeDepsFailure -} from './ssh-relay-build-toolchain' - -describe('buildToolchainProbeCommand', () => { - it('probes make and a C++ compiler and detects the package manager', () => { - const cmd = buildToolchainProbeCommand() - expect(cmd).toContain('command -v "$t"') - expect(cmd).toMatch(/\bmake\b/) - expect(cmd).toMatch(/g\+\+/) - expect(cmd).toContain('apt-get') - // Single POSIX-sh line (runs under `/bin/sh -c`), no embedded single quotes - // that shellEscape would have to wrap. - expect(cmd).not.toContain('\n') - expect(cmd).not.toContain("'") - }) -}) - -describe('parseBuildToolchainProbe', () => { - it('flags a missing toolchain when make and the C++ compiler are absent', () => { - const status = parseBuildToolchainProbe('PKG apt-get\n') - expect(status.toolchainMissing).toBe(true) - expect(status.present).toEqual([]) - expect(status.packageManager).toBe('apt-get') - }) - - it('treats a full toolchain as present', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE gcc\nHAVE g++\nHAVE python3\nPKG dnf') - expect(status.toolchainMissing).toBe(false) - expect(status.present).toContain('make') - expect(status.present).toContain('g++') - expect(status.packageManager).toBe('dnf') - }) - - it('still flags missing when make is present but no C++ compiler is', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE gcc\nHAVE python3') - expect(status.toolchainMissing).toBe(true) - expect(status.packageManager).toBeNull() - }) - - it('accepts clang++ as the C++ compiler', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE clang\nHAVE clang++\nHAVE python3') - expect(status.toolchainMissing).toBe(false) - }) - - it('ignores shell noise around the markers', () => { - const status = parseBuildToolchainProbe( - 'Welcome to Acme\nHAVE make\nHAVE g++\nHAVE python3\nMOTD line\nPKG apk' - ) - expect(status.toolchainMissing).toBe(false) - expect(status.packageManager).toBe('apk') - }) -}) - -describe('shouldProbeBuildToolchainAfterNativeDepsFailure', () => { - it('matches node-gyp missing build-tool output', () => { - expect( - shouldProbeBuildToolchainAfterNativeDepsFailure('gyp ERR! stack Error: not found: make') - ).toBe(true) - expect( - shouldProbeBuildToolchainAfterNativeDepsFailure( - 'node-gyp ERR! Could not find any Python installation' - ) - ).toBe(true) - }) - - it('does not match unrelated npm failures', () => { - expect(shouldProbeBuildToolchainAfterNativeDepsFailure('npm ERR! network ETIMEDOUT')).toBe( - false - ) - expect( - shouldProbeBuildToolchainAfterNativeDepsFailure('npm ERR! E404 Not Found node-pty') - ).toBe(false) - }) -}) - -describe('formatMissingToolchainError', () => { - it('lists the missing tools and a package-manager-specific install command', () => { - const status = parseBuildToolchainProbe('PKG apt-get') - const msg = formatMissingToolchainError(status, 'gyp ERR! not found: make') - expect(msg).toContain('make') - expect(msg).toContain('a C++ compiler (g++ or clang++)') - expect(msg).toContain('python3') - expect(msg).toContain('sudo apt-get install -y build-essential python3') - // Tailored hint replaces the generic distro list. - expect(msg).not.toContain('Fedora/RHEL:') - // Original error retained for triage. - expect(msg).toContain('gyp ERR! not found: make') - }) - - it('falls back to a multi-distro list when no package manager was detected', () => { - const status = parseBuildToolchainProbe('') - const msg = formatMissingToolchainError(status, 'exit 1') - expect(msg).toContain('Debian/Ubuntu:') - expect(msg).toContain('Fedora/RHEL:') - expect(msg).toContain('Arch:') - expect(msg).toContain('Alpine:') - }) - - it('only lists the genuinely missing pieces', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE python3\nPKG pacman') - const msg = formatMissingToolchainError(status, 'err') - expect(msg).toContain('a C++ compiler (g++ or clang++)') - expect(msg).not.toMatch(/\(make,/) - expect(msg).toContain('sudo pacman -S --needed base-devel python') - }) -}) - -describe('formatSkippedNodePtyWarning', () => { - it('quotes the tailored install command when a package manager was detected', () => { - const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe('PKG dnf')) - expect(warning).toContain('skipping node-pty') - expect(warning).toContain('sudo dnf install -y make gcc gcc-c++ python3') - }) - - it('stays distro-neutral when no package manager was detected', () => { - // The hint list is the cross-distro menu here; quoting its first line would name Debian on any host. - const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe('')) - expect(warning).not.toContain('apt-get') - expect(warning).toContain('install a C/C++ toolchain') - }) -}) - -// Verbatim shape of the STA-6674 failure: node-gyp on a host whose nodejs.org is refused. -const HEADERS_REFUSED = - 'npm error gyp http GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz\n' + - 'npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\n' + - 'npm error gyp ERR! configure error\n' + - 'npm error gyp ERR! stack FetchError: request to https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz failed, reason: connect ECONNREFUSED 127.0.0.1:443' - -describe('isNodeHeadersDownloadFailure', () => { - it('matches node-gyp failing to fetch the Node headers tarball', () => { - expect(isNodeHeadersDownloadFailure(HEADERS_REFUSED)).toBe(true) - expect( - isNodeHeadersDownloadFailure( - 'gyp http fetch GET https://nodejs.org/download/release/v20.19.0/node-v20.19.0-headers.tar.gz attempt 1 failed with ENOTFOUND\ngyp ERR! configure error' - ) - ).toBe(true) - }) - - it('is not the toolchain diagnosis, and does not fire on other network failures', () => { - expect(shouldProbeBuildToolchainAfterNativeDepsFailure(HEADERS_REFUSED)).toBe(false) - // The registry, not nodejs.org: a different remedy. - expect( - isNodeHeadersDownloadFailure( - 'npm error network request to https://registry.npmjs.org/node-pty failed, reason: connect ECONNREFUSED' - ) - ).toBe(false) - // Headers named but the build failed for another reason. - expect( - isNodeHeadersDownloadFailure( - 'gyp info using node-v24.12.0-headers.tar.gz\ngyp ERR! build error make failed with exit code: 2' - ) - ).toBe(false) - // A retried attempt that recovered, then a compile failure: not a download failure. - expect( - isNodeHeadersDownloadFailure( - 'gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNRESET\n' + - 'gyp http 200 https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz\n' + - 'gyp ERR! build error\ngyp ERR! stack Error: `make` failed with exit code: 2' - ) - ).toBe(false) - // A mirror answering non-2xx is a FetchError without a network code: a different remedy. - expect( - isNodeHeadersDownloadFailure( - 'gyp ERR! configure error\ngyp ERR! stack FetchError: 404 Not Found https://mirror/dist/v24.12.0/node-v24.12.0-headers.tar.gz' - ) - ).toBe(false) - }) -}) - -describe('formatNodeHeadersDownloadError', () => { - it('names both host remedies when the host ships no headers', () => { - const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED, null) - expect(msg).toContain('no local headers matching its own version') - expect(msg).toContain('/include/node') - expect(msg).toContain('nvm, fnm, volta, n') - expect(msg).toContain('disturl') - expect(msg).toContain('ECONNREFUSED') - }) - - it('reports an Orca defect, not a host problem, when headers were exported and ignored', () => { - const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED, '/usr/local') - expect(msg).toContain('/usr/local/include/node') - expect(msg).toContain('Orca defect') - expect(msg).not.toContain('no local headers matching its own version') - expect(msg).not.toContain('nvm, fnm, volta, n') - expect(msg).toContain('ECONNREFUSED') - }) -}) diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts deleted file mode 100644 index db7b6353697e..000000000000 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { execCommand } from './ssh-relay-deploy-helpers' -import type { SshConnection } from './ssh-connection' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { - buildToolchainProbeCommand, - parseBuildToolchainProbe, - type BuildToolchainStatus -} from './build-toolchain-diagnosis' - -// Why re-exported rather than moved outright: `ssh-relay-deploy.ts` and the relay tests -// import the whole diagnosis surface from here, and the split exists for bundle reasons, -// not to redraw the relay's own API. -export { - buildToolchainProbeCommand, - parseBuildToolchainProbe, - shouldProbeBuildToolchainAfterNativeDepsFailure, - toolchainInstallHintLines, - formatSkippedNodePtyWarning, - formatMissingToolchainError, - formatNodeHeadersDownloadError, - isNodeHeadersDownloadFailure -} from './build-toolchain-diagnosis' -export type { BuildToolchainStatus } from './build-toolchain-diagnosis' - -// Best-effort: returns null on Windows hosts (node-pty ships win32 prebuilds, so -// a missing toolchain isn't the failure there) or if the probe itself errors — -// callers fall back to the original install error in those cases. -export async function probeBuildToolchain( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - if (isWindowsRemoteHost(hostPlatform)) { - return null - } - try { - const output = await execCommand(conn, buildToolchainProbeCommand(), { - wrapCommand: true, - signal - }) - return parseBuildToolchainProbe(output) - } catch { - signal?.throwIfAborted() - return null - } -} diff --git a/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts b/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts deleted file mode 100644 index b94d2102865d..000000000000 --- a/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { execFileSync, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { appendFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshConnection } from './ssh-connection' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { bundledRipgrepContentKey } from '../ripgrep/bundled-ripgrep-path' -import type { SshTarget } from '../../shared/ssh-types' - -// Why opt-in: needs Docker plus an sshd+node+git image WITHOUT rg (ORCA_REVIEW_SSH_NORG_IMAGE). -const RUN = process.env.ORCA_REVIEW_SSH_BUNDLED_RG === '1' -const REMOTE_REPO = '/tmp/orca-bundled-rg-repo' -const TARGET = 'ios/Notion Web Clipper/AppDelegate.swift' -const REPORT = process.env.ORCA_REVIEW_REPORT_FILE - -type Fixture = { containerName: string; identityFile: string; port: number; tempDir: string } - -function run(command: string, args: string[], timeout = 60_000): string { - return execFileSync(command, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout - }) - .toString() - .trim() -} - -function dockerExec(fixture: Fixture, command: string): string { - return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 120_000) -} - -function report(line: string): void { - if (REPORT) { - appendFileSync(REPORT, `${line}\n`) - } -} - -function startTarget(): Fixture { - const image = process.env.ORCA_REVIEW_SSH_NORG_IMAGE ?? 'orca-ssh-norg:latest' - const tempDir = mkdtempSync(join(tmpdir(), 'orca-bundled-rg-ssh-')) - const identityFile = join(tempDir, 'id_ed25519') - run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) - const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() - const containerName = `orca-bundled-rg-${randomUUID().slice(0, 12)}` - run('docker', [ - 'run', - '-d', - '--name', - containerName, - '-p', - '127.0.0.1::22', - '-e', - `AUTHORIZED_KEY=${publicKey}`, - image, - 'bash', - '-lc', - 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' - ]) - const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) - const fixture = { containerName, identityFile, port, tempDir } - // Why 25k files: past the 20,001-path listing cap, so only a real rg search finds the target. - dockerExec( - fixture, - [ - `mkdir -p '${REMOTE_REPO}' && cd '${REMOTE_REPO}' && git init -q`, - `for d in $(seq 0 249); do mkdir -p a$d && (cd a$d && for i in $(seq 0 99); do : > f$i.txt; done); done`, - `mkdir -p 'ios/Notion Web Clipper' && echo 'class AppDelegate {}' > '${TARGET}'`, - 'git add -A >/dev/null' - ].join(' && ') - ) - return fixture -} - -function stopTarget(fixture: Fixture | null): void { - if (!fixture) { - return - } - spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 }) - rmSync(fixture.tempDir, { recursive: true, force: true }) -} - -function createConnection(fixture: Fixture): SshConnection { - const target: SshTarget = { - id: `bundled-rg-${randomUUID()}`, - label: 'Bundled ripgrep Docker SSH target', - source: 'manual', - host: '127.0.0.1', - port: fixture.port, - username: 'root', - identityFile: fixture.identityFile, - identitiesOnly: true - } - return new SshConnection(target, { onStateChange: vi.fn() }) -} - -async function waitFor(check: () => boolean, timeoutMs: number): Promise { - const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - if (check()) { - return Date.now() - startedAt - } - await new Promise((resolve) => setTimeout(resolve, 250)) - } - throw new Error(`condition not met within ${timeoutMs}ms`) -} - -describe.skipIf(!RUN)('SSH relay bundled ripgrep', () => { - let fixture: Fixture | null = null - - beforeAll(() => { - fixture = startTarget() - }, 300_000) - - afterAll(() => { - stopTarget(fixture) - }) - - it('installs Orca ripgrep on a host without rg and serves Quick Open search with it', async () => { - const active = fixture! - expect(dockerExec(active, 'command -v rg || echo NO_RG')).toBe('NO_RG') - const platform = /^(aarch64|arm64)$/.test(dockerExec(active, 'uname -m')) - ? 'linux-arm64' - : 'linux-x64' - const entry = `${bundledRipgrepContentKey(platform)}-${platform}` - const remoteBinary = `/root/.orca-remote/ripgrep/${entry}/rg` - const connection = createConnection(active) - await connection.connect() - try { - const deployStartedAt = Date.now() - const deployed = await deployAndLaunchRelay(connection, undefined, 60) - const deployMs = Date.now() - deployStartedAt - const installMs = await waitFor( - () => dockerExec(active, `test -x '${remoteBinary}' && echo yes || echo no`) === 'yes', - 60_000 - ) - report( - `SSH deploy_ms=${deployMs} rg_installed_after_launch_ms=${installMs} platform=${platform}` - ) - expect(dockerExec(active, `'${remoteBinary}' --version | head -1`)).toContain('ripgrep') - expect(dockerExec(active, "ps -eo args | grep '[r]elay.js --detached'")).toContain( - `--ripgrep-path ${remoteBinary}` - ) - expect(dockerExec(active, `ls /root/.orca-remote/ripgrep/${entry}`)).toBe('rg') - - const mux = new SshChannelMultiplexer(deployed.transport) - try { - await mux.request('session.registerRoot', { rootPath: REMOTE_REPO }) - const searchStartedAt = Date.now() - const files = await mux.request('fs.listFiles', { - rootPath: REMOTE_REPO, - searchQuery: 'AppDelegate', - maxResults: 33 - }) - report( - `SSH quick_open_search_ms=${Date.now() - searchStartedAt} result=${JSON.stringify(files)}` - ) - expect(files).toEqual([TARGET]) - - const textStartedAt = Date.now() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: relay fs.search resolves a SearchResult. - const text = (await mux.request('fs.search', { - query: 'class AppDelegate', - rootPath: REMOTE_REPO - })) as { files: { relativePath: string }[] } - report(`SSH text_search_ms=${Date.now() - textStartedAt} files=${text.files.length}`) - expect(text.files.map((file) => file.relativePath)).toEqual([TARGET]) - } finally { - mux.dispose() - } - } finally { - await connection.disconnect() - } - }, 300_000) - - it('reuses the installed binary on the next deploy without re-uploading', async () => { - const active = fixture! - const before = dockerExec(active, 'stat -c %Y /root/.orca-remote/ripgrep/*/rg') - const connection = createConnection(active) - await connection.connect() - try { - await deployAndLaunchRelay(connection, undefined, 60) - await new Promise((resolve) => setTimeout(resolve, 3_000)) - expect(dockerExec(active, 'stat -c %Y /root/.orca-remote/ripgrep/*/rg')).toBe(before) - expect(dockerExec(active, 'ls -A /root/.orca-remote/ripgrep | grep -c upload || true')).toBe( - '0' - ) - } finally { - await connection.disconnect() - } - }, 300_000) -}) diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts deleted file mode 100644 index 18624eb170dc..000000000000 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -// Cross-version isolation guard. -// -// Why: this test is the executable form of the "Pattern Note" in -// docs/ssh-relay-versioned-install-dirs.md — it asserts that a v2 deploy -// targeting a remote where a v1 daemon is already running NEVER touches -// v1's install dir or socket. Without this test a future refactor that -// collapses to a shared dir passes every other unit test and re-introduces -// the original "stale daemon serves new client" bug. - -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+222222222222') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('not-needed') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import type { SshConnection } from './ssh-connection' -import { REMOTE_INSTALL_ORDER_OK } from './remote-install-previous-version' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - // Why: production attaches and removes real SFTP/write-stream listeners, so the fake must be an emitter. - sftp: vi.fn().mockImplementation(() => { - const sftp = new EventEmitter() - return Promise.resolve( - Object.assign(sftp, { - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - // Shell home and SFTP start directory agree here, so no namespace redirect applies. - realpath: vi.fn((_p: string, cb: (err: Error | null, resolved: string) => void) => - cb(null, '/home/u') - ), - lstat: vi.fn((_p: string, cb: (err: Error | null) => void) => - cb(Object.assign(new Error('No such file'), { code: 2 })) - ), - createWriteStream: vi.fn().mockImplementation(() => { - const ws = new EventEmitter() - return Object.assign(ws, { - end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) - }) - }), - end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) - }) - ) - }) - } as unknown as SshConnection -} - -describe('cross-version isolation', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - }) - - it('a v2 deploy never references the v1 install dir or v1 socket path', async () => { - const conn = makeMockConnection() - const mockExec = vi.mocked(execCommand) - - // Simulated remote where: - // v1 dir = ~/.orca-remote/relay-0.1.0+111111111111/ (live daemon, listening) - // v2 dir = ~/.orca-remote/relay-0.1.0+222222222222/ (does not yet exist) - // The v2 client has fullVersion='0.1.0+222222222222' (from the fs mock above). - // - mockExec.mockImplementation((_conn, command) => { - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__')) { - return Promise.resolve( - '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0' - ) - } - if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__')) { - return Promise.resolve( - '__ORCA_UPLOAD_STAGE_PROMOTION__.sftp-namespace-00000000000000000000000000000000:PROMOTED' - ) - } - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/u') - } - if (command.includes("-name 'relay-0.1.0+222222222222.upload-*'")) { - return Promise.resolve('') - } - if (command.includes('relay-watcher.js') && command.includes('.install-complete')) { - return Promise.resolve('MISSING') - } - if (command.includes('.gc-claim') && command.includes('echo LOCKED || echo OPEN')) { - return Promise.resolve('OPEN') - } - if (command.startsWith('if mkdir ') && command.includes('.install-lock')) { - return Promise.resolve('OK') - } - if (command.includes('ORCA-NPTY-PROBE-OK')) { - return Promise.resolve('ORCA-NPTY-PROBE-OK\n') - } - if (command.includes('process.stdout.write("READY")')) { - return Promise.resolve('READY') - } - if (command.includes('test -S') && command.includes('echo ALIVE || echo DEAD')) { - return Promise.resolve('DEAD') - } - if (command.includes(REMOTE_INSTALL_ORDER_OK)) { - // A newer v0 is the previous build, so v1 is kept only by its live socket. - return Promise.resolve( - `relay-0.1.0+222222222222\nrelay-0.1.0+000000000000\nrelay-0.1.0+111111111111\n${REMOTE_INSTALL_ORDER_OK}` - ) - } - if (command.includes('__ORCA_RELAY_GC_FIND_STATUS__')) { - return Promise.resolve('relay-0.1.0+111111111111\nrelay-0.1.0+222222222222\n') - } - if (command.includes('relay-0.1.0+111111111111/.install-lock')) { - return Promise.resolve('OPEN') - } - if (command.includes('relay-0.1.0+111111111111/.install-complete')) { - return Promise.resolve('COMPLETE') - } - if (command.includes('relay-0.1.0+111111111111') && command.includes('relay-*.sock')) { - return Promise.resolve('ALIVE') - } - return Promise.resolve('') - }) - - await deployAndLaunchRelay(conn) - await vi.waitFor(() => - expect(mockExec.mock.calls.some(([, command]) => command.includes('relay-*.sock'))).toBe(true) - ) - - const allCmds = [ - ...mockExec.mock.calls.map(([, c]) => c), - ...vi.mocked(conn.exec).mock.calls.map(([c]) => c as string) - ] - - // (a) the v2 deploy creates dirs/files under its content-hashed directory - expect(allCmds.some((c) => c.includes('relay-0.1.0+222222222222'))).toBe(true) - - // (b) the v2 launch and connect socket paths are rooted in v2 dir, never v1 - const launchAndConnectCmds = vi - .mocked(conn.exec) - .mock.calls.map(([c]) => c as string) - .filter((c) => c.includes('--sock-path')) - expect(launchAndConnectCmds.length).toBeGreaterThan(0) - for (const cmd of launchAndConnectCmds) { - expect(cmd).toContain('relay-0.1.0+222222222222') - expect(cmd).not.toContain('relay-0.1.0+111111111111') - } - - // (c) GC observes v1 has a live socket and never issues an rm -rf for it - const v1RemoveCmds = allCmds.filter( - (c) => c.includes('rm -rf') && c.includes('relay-0.1.0+111111111111') - ) - expect(v1RemoveCmds).toHaveLength(0) - - // (d) blanket isolation: every command that mentions v1hash MUST be a - // GC liveness probe (`ls`, `test -d`, `test -f`, or `for f in .../*.sock`) - // — never a write, mkdir, chmod, touch, rm, node launch, or socket poll. - // This prevents a future refactor that accidentally writes to the v1 dir - // (e.g. shared install-complete, upload over symlink) from passing. - const v1Refs = allCmds.filter((c) => c.includes('relay-0.1.0+111111111111')) - for (const cmd of v1Refs) { - const isReadOnlyProbe = - /^\s*ls\b/.test(cmd) || - /\btest -d\b/.test(cmd) || - /\btest -e\b/.test(cmd) || - /\btest -f\b/.test(cmd) || - /\btest -S\b/.test(cmd) || - /\bfor f in .*\.sock\b/.test(cmd) - expect(isReadOnlyProbe, `unexpected v1 reference: ${cmd}`).toBe(true) - } - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 6c47a08e1aeb..41a8022f0be1 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -2,7 +2,6 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import type { ClientChannel } from 'ssh2' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { shouldProbeBuildToolchainAfterNativeDepsFailure } from './ssh-relay-build-toolchain' import { RELAY_SENTINEL } from './relay-protocol' import { RelayVersionMismatchError, @@ -400,30 +399,6 @@ describe('execCommand', () => { expect(error.message.length).toBeLessThan(1024 * 1024 + 200) }) - it('keeps the merged error message greppable by the build-toolchain probe', async () => { - const channel = createMockChannel() - const conn = { - exec: vi.fn().mockResolvedValue(channel) - } - const commandPromise = execCommand(conn as never, 'npm install 2>&1') - - await Promise.resolve() - // Why: the OpenSSH banner alone never matches the probe, so the old - // `stderr || stdout` masking suppressed the actionable "install build - // tools" hint. The merged message keeps the node-gyp failure visible. - channel.stderr.emit('data', Buffer.from("Warning: Permanently added 'host' (ED25519)\n")) - channel.emit( - 'data', - Buffer.from( - 'npm error gyp ERR! configure error\nnpm error gyp ERR! stack Error: not found: make\n' - ) - ) - channel.emit('close', 1) - - const error = await execCommandRejection(commandPromise) - expect(shouldProbeBuildToolchainAfterNativeDepsFailure(error.message)).toBe(true) - }) - it('cleans command channel listeners when a command times out', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts deleted file mode 100644 index 7c5676f572da..000000000000 --- a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn(() => 'linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn(), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { name: 'AbortError' }) -})) - -vi.mock('./ssh-relay-superseded-endpoints', () => ({ - sweepSupersededRelayEndpoints: vi.fn().mockResolvedValue([]) -})) -import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { gcOldRelayVersions } from './ssh-relay-versioned-install' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { RelayCredentialMismatchError } from './ssh-relay-credential-mismatch-error' -import { - RelayProbeCleanupUnconfirmedError, - isRelayEndpointHeldError, - isRelayEndpointUnresponsiveError -} from './ssh-relay-endpoint-incumbent' -import type { SshConnection } from './ssh-connection' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection -} - -// The daemon is present and its listener accepts (a SIGSTOPped relay still does — the kernel -// backlog answers), but nothing on the host can enumerate who holds the socket. -const LIVE_UNENUMERABLE_PROBE = [ - 'ORCA-INCUMBENT-BEGIN', - 'PRESENT=yes', - 'LISTEN=accepted', - 'HOLDERS_SOURCE=unavailable', - 'ORCA-INCUMBENT-END' -].join('\n') - -function queueAliveSocketThenProbe(output = LIVE_UNENUMERABLE_PROBE): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('ALIVE') - .mockResolvedValueOnce(output) -} - -function launchedDaemon(conn: SshConnection): boolean { - return vi.mocked(conn.exec).mock.calls.some(([command]) => String(command).includes('--detached')) -} - -/** - * The `--connect` probe's catch block predates the incumbent probe and used to swallow every - * error as "socket probe failed, launch fresh". With a live incumbent that is the collision the - * probe exists to prevent: the fresh daemon loses the bind by luck, not by design. - */ -describe('deployAndLaunchRelay honours the incumbent verdict', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(sweepSupersededRelayEndpoints).mockReset().mockResolvedValue([]) - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - it('does not launch over a live relay that never answered; the error is retryable', async () => { - const conn = makeMockConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('Relay failed to start within 10s')) - queueAliveSocketThenProbe() - - await expect(deployAndLaunchRelay(conn)).rejects.toSatisfy(isRelayEndpointUnresponsiveError) - expect(launchedDaemon(conn)).toBe(false) - }) - - it('does not launch over a live relay that refused the credential; the error is terminal', async () => { - const conn = makeMockConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new RelayCredentialMismatchError('')) - queueAliveSocketThenProbe() - - await expect(deployAndLaunchRelay(conn)).rejects.toSatisfy(isRelayEndpointHeldError) - expect(launchedDaemon(conn)).toBe(false) - }) - - it('does not launch while the incumbent probe cleanup is unconfirmed', async () => { - const conn = makeMockConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('Relay handshake timed out')) - queueAliveSocketThenProbe( - LIVE_UNENUMERABLE_PROBE.replace( - 'HOLDERS_SOURCE=unavailable', - 'HOLDERS_SOURCE=unavailable\nPROBE_CLEANUP=unconfirmed' - ) - ) - await expect(deployAndLaunchRelay(conn)).rejects.toBeInstanceOf( - RelayProbeCleanupUnconfirmedError - ) - expect(launchedDaemon(conn)).toBe(false) - }) - - it('still launches fresh when the socket probe itself fails', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockRejectedValueOnce(new Error('test -S: transport hiccup')) - .mockResolvedValueOnce('READY') - vi.mocked(waitForSentinel).mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - - await deployAndLaunchRelay(conn) - expect(launchedDaemon(conn)).toBe(true) - }) - it.each([ - { error: new Error('completed sweep read failure'), expectedGcCalls: 1 }, - { error: new RelayProbeCleanupUnconfirmedError(), expectedGcCalls: 0 } - ])( - 'runs background GC only after probe cleanup is settled: $error.name', - async ({ error, expectedGcCalls }) => { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') - vi.mocked(waitForSentinel).mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(sweepSupersededRelayEndpoints).mockRejectedValueOnce(error) - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('not-needed') - const result = await deployAndLaunchRelay(makeMockConnection()) - await vi.waitFor(() => expect(sweepSupersededRelayEndpoints).toHaveBeenCalledOnce()) - await new Promise((resolve) => setImmediate(resolve)) - expect(gcOldRelayVersions).toHaveBeenCalledTimes(expectedGcCalls) - const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 120_000) - try { - await result.prepareOpenCodeRuntime?.(new AbortController().signal) - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(expectedGcCalls + 1) - } finally { - now.mockRestore() - } - } - ) -}) diff --git a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts deleted file mode 100644 index caaf3919a033..000000000000 --- a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts +++ /dev/null @@ -1,907 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - isSshRelayOnHostNodeRuntime, - recordSshRelayRuntimeStep -} from './ssh-host-node-runtime-mode' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' -import type * as OrcadRemoteNodeRuntimeModule from './orcad-remote-node-runtime' -import type * as PinnedNodeInstallModule from './ssh-relay-pinned-node-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront -// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report -// the local relay package as existing AND return a content-hashed version -// string so readLocalFullVersion succeeds. -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - 'sshChannelCloseConfirmed' in error && - error.sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: this file mocks fs, so the real content hash cannot read a binary. -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: () => null, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -// Why: the versioned-install modules shell out for install state, locking, -// and GC. Stub them so deploy tests need no real SSH connection. -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -vi.mock('./ssh-relay-pinned-node', async (importOriginal) => ({ - ...(await importOriginal>()), - planPinnedNodeRelay: vi.fn(), - resolvePinnedRelayTargetFacts: vi.fn() -})) - -vi.mock('./ssh-relay-pinned-node-install', async (importOriginal) => ({ - ...(await importOriginal>()), - ensurePinnedRelayRuntime: vi.fn().mockResolvedValue(undefined), - verifyPinnedRelayInstall: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./orcad-remote-node-runtime', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - ensureRemoteOrcadNodeRuntime: vi.fn(actual.ensureRemoteOrcadNodeRuntime) - } -}) - -vi.mock('./ssh-relay-host-node-addons', async (importOriginal) => ({ - ...(await importOriginal>()), - planHostNodeAddonRelay: vi.fn() -})) - -vi.mock('./ssh-target-registry', () => ({ getSshTargetRegistryStore: vi.fn(() => null) })) -vi.mock('../telemetry/client', () => ({ track: vi.fn() })) - -vi.mock('./remote-node-runtime-store-gc', () => ({ - gcRemoteNodeRuntimeStore: vi.fn().mockResolvedValue({ state: 'skipped', reason: 'test' }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import { execCommand } from './ssh-relay-deploy-helpers' -import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { - finalizeInstall, - gcOldRelayVersions, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { - PinnedRelayFallbackError, - planPinnedNodeRelay, - resolvePinnedRelayTargetFacts, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { - MUTABLE_PINNED_REFUSAL_REPLAY_MS, - resetPinnedRuntimeRefusalsForTests -} from './ssh-relay-pinned-refusal-cache' -import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' -import { planHostNodeAddonRelay, type HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' -import { - ensureRemoteOrcadNodeRuntime, - RemoteNodeRuntimeSelfTestError -} from './orcad-remote-node-runtime' -import { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' -import { resetSshRemoteRuntimeTelemetryForTests } from './ssh-remote-runtime-telemetry' -import { getSshTargetRegistryStore } from './ssh-target-registry' -import { track } from '../telemetry/client' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionStore } from './ssh-connection-store' -import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../shared/node-runtime-pin' -import type { SshRemoteRuntime, SshTarget } from '../../shared/ssh-types' -import { terminalUnavailableCauseFromError } from '../../shared/terminal-unavailable-cause' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -const PINNED_VERSION = '0.1.0+feedfacecafe' -const RUNTIME_SHA = NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256 -const PINNED_NODE = `/home/user/.orca-remote/runtimes/node-${RUNTIME_SHA}/bin/node` -const COMPAT_SHA = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 -const COMPAT_NODE = `/home/user/.orca-remote/runtimes/node-${COMPAT_SHA}/bin/node` - -function pinnedPlan(): PinnedRelayPlan { - return { - kind: 'pinned-node', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: PINNED_VERSION, - addons: { dir: '/tmp/addons', digest: 'd', dispose: vi.fn().mockResolvedValue(undefined) }, - runtimeArchive: vi.fn() - } -} - -const HOST_NODE_VERSION = '0.1.0+0123456789ab' - -function hostNodePlan(): HostNodeAddonRelayPlan { - return { - kind: 'host-node-addons', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: HOST_NODE_VERSION, - addons: { dir: '/tmp/host-addons', digest: 'd', dispose: vi.fn().mockResolvedValue(undefined) }, - nodePath: '/opt/node18/bin/node', - hostNode: { version: { major: 18, minor: 20 }, napi: 9 } - } -} - -function makeConnection(remoteRuntime?: SshRemoteRuntime): SshConnection { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deploy path under test touches only these members of the connection. - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - getTarget: () => ({ id: 'target-1', ...(remoteRuntime ? { remoteRuntime } : {}) }), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }) - } as unknown as SshConnection -} - -function detachedLaunchCommand(conn: SshConnection): string | undefined { - return vi - .mocked(conn.exec) - .mock.calls.map(([cmd]) => String(cmd)) - .find((cmd) => cmd.includes('--detached')) -} - -function queueInstalledPinnedLaunch(): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') -} - -function queueInstalledLegacyLaunch(): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') -} - -/** A Linux host whose host-Node relay is installed and launches, answered by command text. */ -function answerHostNodeLaunchByCommand(): void { - const answers: [string, string][] = [ - ['uname', '__ORCA_REMOTE_PLATFORM__ Linux x86_64'], - ['process.stdout.write("READY")', 'READY'], - ['ORCA-NATIVE-DEPS-OK', 'ORCA-NATIVE-DEPS-OK'], - ['test -S', 'DEAD'] - ] - vi.mocked(execCommand).mockImplementation(async (_conn, command) => - command === 'echo $HOME' - ? '/home/user' - : (answers.find(([needle]) => command.includes(needle))?.[1] ?? '') - ) -} - -describe('deployAndLaunchRelay on the pinned Node runtime', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(planPinnedNodeRelay).mockReset().mockResolvedValue(pinnedPlan()) - vi.mocked(ensurePinnedRelayRuntime).mockReset().mockResolvedValue(undefined) - vi.mocked(resolvePinnedRelayTargetFacts) - .mockReset() - .mockResolvedValue({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } }) - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValue(new PinnedRelayFallbackError('libc_floor', 'addons refused')) - vi.mocked(getSshTargetRegistryStore).mockReset().mockReturnValue(null) - resetSshRemoteRuntimeTelemetryForTests() - resetPinnedRuntimeRefusalsForTests() - }) - - it('runs the pinned ladder when the host has no runtime setting and nothing recorded', async () => { - const conn = makeConnection() - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.nodePath).toBe(PINNED_NODE) - expect(result.serverBuildId).toBe(PINNED_VERSION) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => String(cmd).includes('NATIVE-DEPS')) - ).toBe(false) - }) - - it('keeps the host-npm path for a host that opts into Host Node', async () => { - const conn = makeConnection('legacy') - queueInstalledLegacyLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(planPinnedNodeRelay).not.toHaveBeenCalled() - expect(result.nodePath).toBe('/usr/bin/node') - expect(result.serverBuildId).toBe('0.1.0+abcdef012345') - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - }) - - it('clears the Host Node marker once a later connect launches on the ladder', async () => { - recordSshRelayRuntimeStep('target-1', true) - queueInstalledPinnedLaunch() - - await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(false) - }) - - it('launches from the runtime-folded version dir with the pinned Node and no host Node probe', async () => { - const conn = makeConnection('pinned-node') - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.serverBuildId).toBe(PINNED_VERSION) - expect(result.remoteRelayDir).toBe(`/home/user/.orca-remote/relay-${PINNED_VERSION}`) - expect(result.nodePath).toBe(PINNED_NODE) - expect(detachedLaunchCommand(conn)).toContain(`'${PINNED_NODE}' relay.js --detached`) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => String(cmd).includes('NATIVE-DEPS')) - ).toBe(false) - }) - - it('collects the runtime store after a launch, keeping the pin it runs', async () => { - const conn = makeConnection('pinned-node') - queueInstalledPinnedLaunch() - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - await vi.waitFor(() => expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledOnce()) - expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledWith( - conn, - expect.objectContaining({ os: 'linux' }), - '/home/user', - // The compat runtime stays pinned too, so a rung A connect never collects rung B's. - { currentPins: [RUNTIME_SHA, COMPAT_SHA] } - ) - // Why after: the version pass is what drops the refs that held superseded runtimes. - expect(vi.mocked(gcOldRelayVersions).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(gcRemoteNodeRuntimeStore).mock.invocationCallOrder[0] - ) - }) - - it('leaves the runtime store alone on the legacy host-Node path', async () => { - const conn = makeConnection('legacy') - queueInstalledLegacyLaunch() - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalled()) - await new Promise((resolve) => setImmediate(resolve)) - expect(gcRemoteNodeRuntimeStore).not.toHaveBeenCalled() - }) - - it('falls back to the host-Node relay, as before the ladder, once A, B and C all refuse', async () => { - const conn = makeConnection() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( - new PinnedRelayFallbackError('missing_lib', 'libstdc++.so.6: cannot open') - ) - vi.mocked(planPinnedNodeRelay) - .mockResolvedValueOnce(pinnedPlan()) - .mockRejectedValueOnce(new PinnedRelayFallbackError('missing_lib', 'refused')) - answerHostNodeLaunchByCommand() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(result.nodePath).toBe('/usr/bin/node') - // The fallback's strict probe found Node once; the launch reuses it. - expect(resolveRemoteNodePath).toHaveBeenCalledOnce() - expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung A unavailable (missing_lib)')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung B unavailable (missing_lib)')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung C unavailable (libc_floor)')) - warn.mockRestore() - }) - - it('keeps the host-Node relay, never rung D or a persisted decision, when this build lacks the orcad template', async () => { - const registry = { - getTarget: vi.fn(() => ({ id: 'target-1' })), - updateTarget: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const missing = 'The packaged orcad deployment template is missing' - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('artifacts_unavailable', 'refused') - ) - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValueOnce(new PinnedRelayFallbackError('artifacts_unavailable', missing)) - const conn = makeConnection() - queueInstalledLegacyLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(result.nodePath).toBe('/usr/bin/node') - expect(detachedLaunchCommand(conn)).toContain("'/usr/bin/node' relay.js --detached") - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - expect(registry.updateTarget).not.toHaveBeenCalled() - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ rung: 'legacy', first_refusal: 'artifacts_unavailable' }) - ) - }) - - it('settles rung D after a client gap only once the host-Node fallback proves no Node', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('artifacts_unavailable', 'refused') - ) - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - - const failure = await deployAndLaunchRelay( - makeConnection(), - undefined, - undefined, - 'target-1' - ).catch((error: unknown) => error) - - expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(String(failure)).toContain('could not prepare its bundled Node.js') - expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ reason: 'no_runtime' }) - expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) - }) - - function queueWindowsPlatformProbe(): SshConnection { - const conn = makeConnection() - Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) - vi.mocked(execCommand) - .mockRejectedValueOnce(new Error('uname not found')) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - return conn - } - - it('settles rung D with Windows wording when a Windows host has no Node to fall back to', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('security_software', 'refused') - ) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - const failure = await deployAndLaunchRelay( - queueWindowsPlatformProbe(), - undefined, - 300, - 'target-1' - ).catch((error: unknown) => error) - - expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(String(failure)).toContain('Windows host') - expect(String(failure)).toContain('install Node.js 18+') - expect(String(failure)).not.toContain('mounted noexec') - expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ - reason: 'no_runtime', - host: { platform: 'win32' } - }) - }) - - it('keeps an unanswered host-Node probe a retryable failure, not rung D', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('security_software', 'refused') - ) - const lost = new Error('channel closed') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(lost) - - await expect( - deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1') - ).rejects.toBe(lost) - }) - - it('replays a Windows security-software refusal for a day, then re-proves rung A', async () => { - const winSha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 - const stored: Partial = {} - const registry = { - getTarget: vi.fn(() => ({ id: 'target-1', ...stored })), - updateTarget: vi.fn((_id: string, updates: Partial) => { - Object.assign(stored, updates) - return null - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ target: 'win32-x64', glibc: null }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('security_software', 'refused') - ) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - await deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1').catch( - () => undefined - ) - - // Persisted with its proof time, so a restart doesn't re-upload to a host that still blocks it. - expect(stored.remoteRuntimeResolution).toMatchObject({ - pinnedRefusal: 'security_software', - runtimeSha256: winSha, - refusedAt: expect.any(Number) - }) - const persisted = vi.mocked(planPinnedNodeRelay).mock.calls[0]![0].persistedRefusal - const winFacts = { target: 'win32-x64' as const, glibc: null } - expect(persisted?.(winFacts)).toBe('security_software') - stored.remoteRuntimeResolution = { - ...stored.remoteRuntimeResolution!, - refusedAt: Date.now() - MUTABLE_PINNED_REFUSAL_REPLAY_MS - } - expect(persisted?.(winFacts)).toBeNull() - }) - - it.each(['missing_lib', 'security_software', 'noexec', 'artifacts_unavailable'] as const)( - 'keeps a Windows host on the host-Node relay after rung A refuses for %s', - async (reason) => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = makeConnection() - Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError(reason, 'refused') - ) - vi.mocked(execCommand) - .mockRejectedValueOnce(new Error('uname not found')) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-1') - - expect(resolveRemoteNodePath).toHaveBeenCalled() - expect(result.serverBuildId).toBe('0.1.0+abcdef012345') - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - } - ) - - it('runs rung B on the glibc 2.17 compat runtime when rung A is below its glibc floor', async () => { - const conn = makeConnection('pinned-node') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const oldGlibc = { major: 2, minor: 17 } - vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ - target: 'linux-x64-glibc', - glibc: oldGlibc - }) - vi.mocked(planPinnedNodeRelay) - .mockRejectedValueOnce(new PinnedRelayFallbackError('libc_floor', 'refused')) - .mockResolvedValueOnce({ ...pinnedPlan(), target: 'linux-x64-glibc217', glibc: oldGlibc }) - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(vi.mocked(planPinnedNodeRelay).mock.calls[1]?.[0]).toMatchObject({ - compat: { target: 'linux-x64-glibc217', glibcFloor: oldGlibc } - }) - expect(result.nodePath).toBe(COMPAT_NODE) - expect(detachedLaunchCommand(conn)).toContain(`'${COMPAT_NODE}' relay.js --detached`) - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - await vi.waitFor(() => expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledOnce()) - expect(vi.mocked(gcRemoteNodeRuntimeStore).mock.calls[0]?.[3]).toEqual({ - currentPins: [RUNTIME_SHA, COMPAT_SHA] - }) - warn.mockRestore() - }) - - it('skips rung B on a current glibc when rung A refused for a reason B cannot answer', async () => { - const conn = makeConnection('pinned-node') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('illegal_instruction', 'refused') - ) - vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - await expect( - deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) - - expect(planPinnedNodeRelay).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('rung B unavailable (runtime_unavailable)') - ) - warn.mockRestore() - }) - - it('does not step down when the runtime check is unverifiable', async () => { - const conn = makeConnection('pinned-node') - const unverifiable = new Error('The pinned Node self-test is unverifiable') - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(unverifiable) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - - await expect(deployAndLaunchRelay(conn, undefined, undefined, 'target-1')).rejects.toBe( - unverifiable - ) - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(detachedLaunchCommand(conn)).toBeUndefined() - // No self-test ran, so there is no unverifiable runtime outcome to report. - expect(track).not.toHaveBeenCalled() - }) - - it('releases the staged addons after the attempt', async () => { - const conn = makeConnection('pinned-node') - const plan = pinnedPlan() - vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce(plan) - queueInstalledPinnedLaunch() - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(plan.addons.dispose).toHaveBeenCalledOnce() - }) - - it('uploads the relay and its addons, then self-tests instead of installing native deps', async () => { - const conn = makeConnection('pinned-node') - const uploadDirectory = vi.fn().mockResolvedValue(undefined) - Object.assign(conn, { uploadDirectory, writeFile: vi.fn().mockResolvedValue(undefined) }) - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) { - return Promise.resolve(`__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0`) - } - if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__') && marker) { - return Promise.resolve(`__ORCA_UPLOAD_STAGE_PROMOTION__${marker}:PROMOTED`) - } - if (command.includes('process.stdout.write("READY")')) { - return Promise.resolve('READY') - } - return Promise.resolve(command.includes('test -S') ? 'DEAD' : '') - }) - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(uploadDirectory.mock.calls.map(([local]) => local)).toContain('/tmp/addons') - expect(ensurePinnedRelayRuntime).toHaveBeenCalledWith( - expect.objectContaining({ - remoteRelayDir: `/home/user/.orca-remote/relay-${PINNED_VERSION}` - }), - false - ) - expect(verifyPinnedRelayInstall).toHaveBeenCalledOnce() - expect(vi.mocked(verifyPinnedRelayInstall).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(finalizeInstall).mock.invocationCallOrder[0] - ) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => /npm (?:install|ci)/.test(String(cmd))) - ).toBe(false) - expect(detachedLaunchCommand(conn)).toContain(`'${PINNED_NODE}' relay.js --detached`) - }) - - it('runs rung C on the host Node with the prebuilt addons and no npm', async () => { - const conn = makeConnection('pinned-node') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('artifacts_unavailable', 'refused') - ) - const plan = hostNodePlan() - vi.mocked(planHostNodeAddonRelay).mockReset().mockResolvedValueOnce(plan) - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(ensurePinnedRelayRuntime).not.toHaveBeenCalled() - expect(result.serverBuildId).toBe(HOST_NODE_VERSION) - expect(result.nodePath).toBe('/opt/node18/bin/node') - expect(detachedLaunchCommand(conn)).toContain("'/opt/node18/bin/node' relay.js --detached") - expect(plan.addons.dispose).toHaveBeenCalledOnce() - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ - rung: 'c', - first_refusal: 'artifacts_unavailable', - host_node_major: '18', - host_libc: 'glibc', - glibc_minor: '31' - }) - ) - }) - - it("steps past rung A to the host's Node when NixOS's stub loader refuses the pinned Node", async () => { - const conn = makeConnection() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const actualInstall = await vi.importActual( - './ssh-relay-pinned-node-install' - ) - vi.mocked(ensurePinnedRelayRuntime).mockImplementationOnce( - actualInstall.ensurePinnedRelayRuntime - ) - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError( - 127, - `Could not start dynamically linked executable: ${PINNED_NODE}\n` + - 'NixOS cannot run dynamically linked executables intended for generic\n' + - 'linux environments out of the box. For more information, see:\n' + - 'https://nix.dev/permalink/stub-ld' - ) - ) - const nixNode = '/run/current-system/sw/bin/node' - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockResolvedValueOnce({ ...hostNodePlan(), nodePath: nixNode }) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('uname')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('process.stdout.write("READY")')) { - return 'READY' - } - return command.includes('test -S') ? 'DEAD' : '' - }) - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce() - expect(result.nodePath).toBe(nixNode) - expect(detachedLaunchCommand(conn)).toContain(`'${nixNode}' relay.js --detached`) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => /NATIVE-DEPS|npm /.test(String(cmd))) - ).toBe(false) - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ rung: 'c', first_refusal: 'wrong_libc' }) - ) - }) - - it('tries the host-Node fallback after a proved noexec, reaching D only on its answer', async () => { - const conn = makeConnection('pinned-node') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( - new PinnedRelayFallbackError('noexec', 'exit 126: Permission denied') - ) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - - const failure = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1').catch( - (error: unknown) => error - ) - - expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(String(failure)).toContain('mounted noexec') - expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ - status: 'blocked', - reason: 'home_noexec', - repairable: false - }) - // B and C load addons from the same tree, so only the fallback can disprove the noexec. - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) - expect(detachedLaunchCommand(conn)).toBeUndefined() - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ rung: 'd', first_refusal: 'noexec' }) - ) - }) - - it('steps down on an answered but unclassified install failure (ENOSPC), not abort', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const enospc = Object.assign( - new Error('Command "tar -xzf" failed (exit 2): tar: write error: No space left on device'), - { exitCode: 2, stdout: '' } - ) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(enospc) - answerHostNodeLaunchByCommand() - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) - - const result = await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - - expect(result.nodePath).toBe('/usr/bin/node') - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - }) - - it('keeps an unanswered install failure retryable rather than stepping down', async () => { - const lost = Object.assign(new Error('channel lost'), { sshChannelCloseConfirmed: false }) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(lost) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - - await expect( - deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - ).rejects.toBe(lost) - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - }) - - it('persists the rung A refusal under the host key and skips A on the next connect', async () => { - const stored: Partial = {} - const registry = { - getTarget: vi.fn(() => ({ id: 'target-1', ...stored })), - updateTarget: vi.fn((_id: string, updates: Partial) => { - Object.assign(stored, updates) - return null - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( - new PinnedRelayFallbackError('illegal_instruction', 'SIGILL') - ) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - await expect( - deployAndLaunchRelay(makeConnection('pinned-node'), undefined, undefined, 'target-1') - ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) - - expect(stored.remoteRuntimeResolution).toMatchObject({ - rung: 'D', - pinnedRefusal: 'illegal_instruction', - glibc: '2.31', - runtimeSha256: RUNTIME_SHA - }) - const persisted = vi.mocked(planPinnedNodeRelay).mock.calls[0]![0].persistedRefusal - expect(persisted?.({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } })).toBe( - 'illegal_instruction' - ) - // A different glibc is a different key: the cached refusal no longer applies. - expect(persisted?.({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 35 } })).toBeNull() - expect(track).toHaveBeenCalledOnce() - }) - - it('launches a Windows relay on node.exe from the runtimes store, with no host Node probe', async () => { - const conn = makeConnection('pinned-node') - Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) - const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 - const nodeExe = `C:/Users/me/.orca-remote/runtimes/node-${sha}/node.exe` - vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ - ...pinnedPlan(), - target: 'win32-x64', - glibc: null - }) - vi.mocked(execCommand) - .mockRejectedValueOnce(new Error('uname not found')) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me') - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.nodePath).toBe(nodeExe) - expect(result.remoteRelayDir).toBe(`C:/Users/me/.orca-remote/relay-${PINNED_VERSION}`) - expect(ensurePinnedRelayRuntime).toHaveBeenCalledWith( - expect.objectContaining({ host: expect.objectContaining({ os: 'win32' }) }), - true - ) - const launchScript = vi - .mocked(execCommand) - .mock.calls.map(([, command]) => decodeRemotePowerShellScript(String(command))) - .find((script) => script.includes('Invoke-CimMethod')) - expect(launchScript).toContain(nodeExe) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts deleted file mode 100644 index 063414ea8b42..000000000000 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: the post-launch ripgrep install is fire-and-forget and would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { ensureRemoteBundledRipgrep } from './ssh-relay-ripgrep-install' -import { RELAY_UPLOAD_STAGE_POOL_NAME } from './ssh-relay-upload-stage-contract' -import { - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, - RELAY_DEPLOY_TIMEOUT_MS -} from './ssh-relay-deploy-timing' -import type { SshConnection } from './ssh-connection' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -function stageCommandResponse(command: string): string | undefined { - const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) { - return `__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0` - } - if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__') && marker) { - return `__ORCA_UPLOAD_STAGE_PROMOTION__${marker}:PROMOTED` - } - return command.includes('.upload-stages') ? '' : undefined -} - -describe('deployAndLaunchRelay staged uploads', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) - }) - - it('aborts an in-progress relay upload at the overall deploy timeout', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - return Promise.resolve(stageCommandResponse(command) ?? '') - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false) - let uploadSignal: AbortSignal | undefined - conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => { - uploadSignal = options?.signal - return new Promise((_resolve, reject) => { - uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { - once: true - }) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(0) - expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(900_000) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - expect(uploadSignal?.aborted).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('joins the bounded teardown window when an aborted transfer never settles', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - return Promise.resolve(stageCommandResponse(command) ?? '') - }) - let uploadSignal: AbortSignal | undefined - conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => { - uploadSignal = options?.signal - return new Promise(() => {}) - }) - - const deployment = deployAndLaunchRelay(conn).catch((error: Error) => error) - await vi.advanceTimersByTimeAsync(0) - expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - expect(uploadSignal?.aborted).toBe(true) - expect( - await Promise.race([deployment.then(() => 'settled'), Promise.resolve('pending')]) - ).toBe('pending') - - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - await expect(deployment).resolves.toMatchObject({ - message: 'Relay deployment timed out after 900s', - sshChannelCloseConfirmed: false, - sshTransferTeardownConfirmed: false - }) - } finally { - vi.useRealTimers() - } - }) - - it('waits for a deferred SFTP upload before acquiring the install lock', async () => { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - let finishUpload: () => void = () => {} - conn.uploadDirectory = vi.fn( - () => - new Promise((resolve) => { - finishUpload = resolve - }) - ) - - const deploy = deployAndLaunchRelay(conn).catch(() => undefined) - await vi.waitFor(() => expect(conn.uploadDirectory).toHaveBeenCalledTimes(1)) - expect(acquireInstallLock).not.toHaveBeenCalled() - finishUpload() - await vi.waitFor(() => expect(acquireInstallLock).toHaveBeenCalledTimes(1)) - await deploy - }) - - it('drops only its stage when a sibling finishes before the locked re-probe', async () => { - const conn = makeMockConnection() - const events: string[] = [] - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockImplementationOnce(async () => { - events.push('initial-probe') - return false - }) - .mockImplementationOnce(async () => { - events.push('locked-re-probe') - return true - }) - vi.mocked(acquireInstallLock).mockImplementationOnce(async () => { - events.push('lock') - }) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockImplementation(async () => { - events.push('upload') - }) - - await deployAndLaunchRelay(conn) - - expect(events).toEqual(['initial-probe', 'upload', 'lock', 'locked-re-probe']) - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('cp -a'))).toBe(false) - const uploadStageRemovals = commands.filter( - (command) => /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - expect(uploadStageRemovals).toHaveLength(1) - expect(uploadStageRemovals[0]).toContain('/.orca-remote/.upload-stages/claim-0') - }) - - it.each(['lock acquisition', 'locked recheck'])( - 'preserves the stage after uncertain %s termination', - async (phase) => { - const conn = makeMockConnection() - const error = Object.assign(new Error('install command still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) - if (phase === 'lock acquisition') { - vi.mocked(acquireInstallLock).mockRejectedValueOnce(error) - } else { - vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(error) - } - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('uname')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - return stageCommandResponse(command) ?? '' - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect( - vi - .mocked(execCommand) - .mock.calls.some( - ([, command]) => - /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - ).toBe(false) - expect(conn.exec).not.toHaveBeenCalled() - } - ) - - it('stops before launch when owned-stage cleanup cannot confirm termination', async () => { - const conn = makeMockConnection() - const error = Object.assign(new Error('stage removal still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(true) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (/\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf')) { - throw error - } - if (command.includes('uname')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - return stageCommandResponse(command) ?? '' - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect(conn.exec).not.toHaveBeenCalled() - expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() - }) - - it('runs bounded fixed-path recovery before a fresh upload', async () => { - const conn = makeMockConnection() - const events: string[] = [] - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('deleting_old=')) { - events.push('recover') - } - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockImplementation(async () => { - events.push('upload') - }) - - await deployAndLaunchRelay(conn) - - expect(events.indexOf('recover')).toBeLessThan(events.indexOf('upload')) - }) - - it('launches before one bounded installed-path recovery exec', async () => { - const conn = makeMockConnection() - const events: string[] = [] - let socketProbe = 0 - vi.mocked(waitForSentinel).mockImplementation(async () => { - events.push('launch-ready') - return { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - } - }) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('deleting_old=')) { - events.push('recover') - } - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ % 2 === 0 ? 'DEAD' : 'READY') - } - return Promise.resolve('') - }) - - for (let deployment = 0; deployment < 12; deployment += 1) { - await deployAndLaunchRelay(conn) - } - - await vi.waitFor(() => expect(events).toHaveLength(24)) - expect(events).toEqual(Array.from({ length: 12 }, () => ['launch-ready', 'recover']).flat()) - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const recoveryCommands = commands.filter((command) => command.includes('deleting_old=')) - expect(recoveryCommands).toHaveLength(12) - expect(recoveryCommands.every((command) => command.includes('.upload-stages'))).toBe(true) - expect(recoveryCommands.every((command) => !command.includes('find "$pool"'))).toBe(true) - }) - - it('never enumerates arbitrary stage paths during installation', async () => { - const conn = makeMockConnection() - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - - await deployAndLaunchRelay(conn) - - expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) - const stageCommands = vi - .mocked(execCommand) - .mock.calls.map(([, command]) => command) - .filter((command) => command.includes('.upload-stages')) - expect(stageCommands.length).toBeGreaterThan(0) - expect(stageCommands.every((command) => !command.includes('-mindepth'))).toBe(true) - }) - - it('keeps the staging tree after an unconfirmed system SSH upload termination', async () => { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - return Promise.resolve( - command.includes('uname') ? '__ORCA_REMOTE_PLATFORM__ Linux x86_64' : '/home/user' - ) - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - const termination = Object.assign(new Error('upload teardown unconfirmed'), { - sshChannelCloseConfirmed: false - }) - conn.uploadDirectory = vi.fn().mockRejectedValue(termination) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination) - expect(acquireInstallLock).not.toHaveBeenCalled() - expect( - vi - .mocked(execCommand) - .mock.calls.some( - ([, command]) => - /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - ).toBe(false) - }) - - it('retries immediately after an unconfirmed upload termination instead of waiting on a fresh install lock', async () => { - const conn = makeMockConnection() - const termination = Object.assign(new Error('upload teardown unconfirmed'), { - sshChannelCloseConfirmed: false - }) - let lockHeld = false - vi.mocked(acquireInstallLock).mockImplementation((_conn, _dir, _host, options) => { - if (!lockHeld) { - lockHeld = true - return Promise.resolve() - } - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { - once: true - }) - }) - }) - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockRejectedValueOnce(termination).mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination) - await deployAndLaunchRelay(conn) - - expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) - expect(acquireInstallLock).toHaveBeenCalledTimes(1) - }) - - it('cleans a confirmed-abort staging tree so the next deployment retries immediately', async () => { - const conn = makeMockConnection() - const termination = Object.assign(new Error('upload aborted'), { - sshChannelCloseConfirmed: true - }) - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockRejectedValueOnce(termination).mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination) - expect( - vi - .mocked(execCommand) - .mock.calls.some( - ([, command]) => - /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - ).toBe(true) - - await deployAndLaunchRelay(conn) - expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) - }) - it('returns the relay before ripgrep finishes but defers stale-stage cleanup', async () => { - const conn = makeMockConnection() - let finishUpload = (): void => {} - vi.mocked(ensureRemoteBundledRipgrep).mockImplementationOnce( - () => - new Promise((resolve) => { - finishUpload = () => resolve('present') - }) - ) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - return Promise.resolve('') - }) - - const deployed = await deployAndLaunchRelay(conn) - expect(deployed.transport).toBeDefined() - await new Promise((resolve) => setImmediate(resolve)) - expect( - vi - .mocked(execCommand) - .mock.calls.some(([, command]) => command.includes(RELAY_UPLOAD_STAGE_POOL_NAME)) - ).toBe(false) - finishUpload() - - await vi.waitFor(() => { - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes(RELAY_UPLOAD_STAGE_POOL_NAME))).toBe(true) - }) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts deleted file mode 100644 index 1036385fac11..000000000000 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -// Windows named-pipe endpoint deploys, split out of ssh-relay-deploy.test.ts: that file sits at -// the max-lines cap, and these four share only the deploy harness with the rest of it. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront -// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report -// the local relay package as existing AND return a content-hashed version -// string so readLocalFullVersion succeeds. -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: this file mocks fs, so the real content hash cannot read a binary. -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: () => null, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -// Why: the versioned-install modules shell out for install state, locking, -// and GC. Stub them so deploy tests need no real SSH connection. -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import type { SshConnection } from './ssh-connection' - -function decodePowerShellCommand(command: string): string | null { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null -} - -const extractWindowsSockPath = (script: string): string => - /--sock-path\s+'([^']+)'/.exec(script)?.[1] ?? '' - -const extractWindowsMarkerPath = (script: string): string => - /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -describe('deployAndLaunchRelay on Windows remotes', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) - }) - - it('launches Windows remotes via a named pipe endpoint', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - expect(result.platform).toBe('win32-x64') - expect(result.remoteHome).toBe('C:/Users/me user') - expect(result.sockPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - expect(execCommands[0]).toContain('powershell.exe') - const decodedScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - const launchScript = decodedScripts.find((script) => script.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"' - ) - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-' - ) - expect(launchScript).toContain('--endpoint-dir') - expect(launchScript).toContain( - '--ripgrep-path "C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe"' - ) - expect(launchScript).not.toContain('--pty-source-credit-v1') - expect(launchScript).not.toContain('.pty-source-credit-policy') - expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') - const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' - expect(waitScript).toContain('setTimeout(attempt,intervalMs)') - const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { - const script = decodePowerShellCommand(command) - return ( - script?.includes('.windows-active-pipe') || - script?.includes('Invoke-CimMethod') || - script?.includes('deadline=Date.now()') - ) - }) - expect(windowsLaunchCalls.length).toBeGreaterThan(0) - expect( - windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) - ).toBe(true) - expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) - expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) - }) - - it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - vi.mocked(waitForSentinel) - .mockRejectedValueOnce(new Error('stale daemon handshake failed')) - .mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe yet - .mockResolvedValueOnce('READY') // existing named pipe probe - .mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running - .mockResolvedValueOnce('') // WMI relay launch on fallback pipe - .mockResolvedValueOnce('READY') // fallback pipe poll - .mockResolvedValueOnce('') // persist fallback active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(2) - const firstConnectScript = decodePowerShellCommand(execCommands[0]) ?? '' - const secondConnectScript = decodePowerShellCommand(execCommands[1]) ?? '' - const primaryPipe = extractWindowsSockPath(firstConnectScript) - const fallbackPipe = extractWindowsSockPath(secondConnectScript) - expect(primaryPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).not.toBe(primaryPipe) - expect(result.sockPath).toBe(fallbackPipe) - - const launchScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find((script) => script?.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain(fallbackPipe) - expect(launchScript).not.toContain(primaryPipe) - - const markerWriteScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find( - (script) => script?.includes('Set-Content') && script.includes('.windows-active-pipe') - ) ?? '' - expect(markerWriteScript).toContain(fallbackPipe) - expect(markerWriteScript).not.toContain(primaryPipe) - }) - - it('prefers a persisted Windows fallback pipe on later reconnects', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker - .mockResolvedValueOnce('READY') // persisted named pipe probe - .mockResolvedValueOnce('') // refresh active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - const connectScript = decodePowerShellCommand(execCommands[0]) ?? '' - expect(extractWindowsSockPath(connectScript)).toBe(persistedPipe) - expect(result.sockPath).toBe(persistedPipe) - - const decodedExecScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - expect(decodedExecScripts.some((script) => script.includes('Invoke-CimMethod'))).toBe(false) - }) - - it('scopes persisted Windows active pipe markers by relay target', async () => { - const connA = makeMockConnection() - const connB = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe A - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe A - .mockResolvedValueOnce('') // deferred stale-stage cleanup A - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe B - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe B - - await deployAndLaunchRelay(connA, undefined, 300, 'target-a') - await new Promise((resolve) => setImmediate(resolve)) - await deployAndLaunchRelay(connB, undefined, 300, 'target-b') - - const markerPaths = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => Boolean(script?.includes('Get-Content'))) - .map(extractWindowsMarkerPath) - - expect(markerPaths).toHaveLength(2) - expect(markerPaths[0]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[1]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[0]).not.toBe(markerPaths[1]) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts deleted file mode 100644 index db38b1951450..000000000000 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ /dev/null @@ -1,915 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront -// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report -// the local relay package as existing AND return a content-hashed version -// string so readLocalFullVersion succeeds. -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: this file mocks fs, so the real content hash cannot read a binary. -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: () => null, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -// Why: the versioned-install modules shell out for install state, locking, -// and GC. Stub them so deploy tests need no real SSH connection. -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { - ensureRemoteBundledRipgrep, - recordRemoteRipgrepReference -} from './ssh-relay-ripgrep-install' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' -import * as DeployTiming from './ssh-relay-deploy-timing' -import type { SshConnection } from './ssh-connection' -import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS -} from '../../shared/ssh-types' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -function queueLaunchNamespaceAndDeadSocketProbe(): void { - vi.mocked(execCommand).mockResolvedValueOnce('').mockResolvedValueOnce('DEAD') -} - -function queueFreshLinuxDeploy(): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - queueLaunchNamespaceAndDeadSocketProbe() - vi.mocked(execCommand).mockResolvedValueOnce('READY') -} - -function detachedLaunchCommand(conn: SshConnection): string | undefined { - return vi - .mocked(conn.exec) - .mock.calls.map(([cmd]) => cmd as string) - .find((cmd) => cmd.includes('--detached')) -} - -describe('deployAndLaunchRelay', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(ensureRemoteOpenCodeRuntime).mockReset().mockResolvedValue('ready') - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) - }) - - it('calls exec to detect remote platform', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - await deployAndLaunchRelay(conn) - - expect(mockExecCommand).toHaveBeenCalledWith( - conn, - "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm", - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ) - }) - - it('reports progress via callback', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - const progress: string[] = [] - await deployAndLaunchRelay(conn, (status) => progress.push(status)) - - expect(progress).toContain('Detecting remote platform...') - expect(progress).toContain('Starting relay...') - }) - - it('does not launch fresh after an unconfirmed endpoint-incumbent probe', async () => { - const conn = makeMockConnection() - const unconfirmedCleanup = Object.assign(new Error('endpoint probe still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed')) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('ALIVE') - .mockRejectedValueOnce(unconfirmedCleanup) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(unconfirmedCleanup) - - const commands = vi.mocked(conn.exec).mock.calls.map(([command]) => command) - expect(commands).toHaveLength(1) - expect( - commands.filter((command) => /--detached|\brm -f\b|\bkill\s/.test(command)) - ).toHaveLength(0) - }) - - it('resolves the remote node path once per deploy', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn) - - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it('resolves node concurrently with remote home, not after the install-state chain', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - - let markNodeResolutionStarted: () => void = () => {} - const nodeResolutionStarted = new Promise((resolve) => { - markNodeResolutionStarted = resolve - }) - vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { - markNodeResolutionStarted() - return Promise.resolve('/usr/bin/node') - }) - - // Hold the first install-state step open. The optimization starts the node - // branch before the remote-home -> install-check chain finishes. - let releaseRemoteHome: (home: string) => void = () => {} - mockExecCommand.mockReturnValueOnce( - new Promise((resolve) => { - releaseRemoteHome = resolve - }) - ) - - const deployPromise = deployAndLaunchRelay(conn) - let assertionError: unknown - let deployError: unknown - try { - await nodeResolutionStarted - - expect(isRelayAlreadyInstalled).not.toHaveBeenCalled() - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - } catch (err) { - assertionError = err - } finally { - // Drain the rest of the happy path so a failed assertion does not leave - // the deploy promise pending until the overall deploy timeout. - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - releaseRemoteHome('/home/user') - deployError = await deployPromise.then( - () => undefined, - (err: unknown) => err - ) - } - if (assertionError) { - throw assertionError - } - if (deployError) { - throw deployError - } - }) - - it('keeps bootstrap sequential when the connection cannot run concurrent exec commands', async () => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - let releaseRemoteHome: (home: string) => void = () => {} - let remoteHomeProbeStarted: () => void = () => {} - const remoteHomeProbeStartedPromise = new Promise((resolve) => { - remoteHomeProbeStarted = resolve - }) - mockExecCommand.mockReturnValueOnce( - new Promise((resolve) => { - remoteHomeProbeStarted() - releaseRemoteHome = resolve - }) - ) - - const deployPromise = deployAndLaunchRelay(conn) - await remoteHomeProbeStartedPromise - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - releaseRemoteHome('/home/user') - await deployPromise - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it('falls back to sequential bootstrap when concurrent SSH sessions are refused', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - const { resolveRemoteNodePath: resolveRemoteNodePathActual } = await vi.importActual< - typeof SshRemoteNodeResolution - >('./ssh-remote-node-resolution') - let fallbackInstallStateCompleted = false - vi.mocked(resolveRemoteNodePath) - .mockImplementationOnce(resolveRemoteNodePathActual) - .mockImplementationOnce(() => { - if (!fallbackInstallStateCompleted) { - throw new Error('Sequential fallback resolved node before install state finished') - } - return Promise.resolve('/usr/bin/node') - }) - vi.mocked(isRelayAlreadyInstalled) - .mockImplementationOnce(async (_conn, _dir, _host, options) => { - expect(options?.rethrowSessionLimitErrors).toBe(true) - return true - }) - .mockImplementationOnce(async (_conn, _dir, _host, options) => { - expect(options?.rethrowSessionLimitErrors).toBeUndefined() - fallbackInstallStateCompleted = true - return true - }) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe - mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - await deployAndLaunchRelay(conn) - - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: true - }) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) - }) - - it('falls back to sequential bootstrap when the install-state probe hits a session limit', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - vi.mocked(isRelayAlreadyInstalled) - .mockImplementationOnce(async (_conn, _dir, _host, options) => { - if (!options?.rethrowSessionLimitErrors) { - return true - } - throw sessionLimitError - }) - .mockResolvedValueOnce(true) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - await deployAndLaunchRelay(conn) - - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: true - }) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: undefined, - signal: expect.any(AbortSignal) - }) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: true - }) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) - }) - - it('does not retry bootstrap for non-session failures', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const nodeError = new Error('Node.js not found on remote host') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(nodeError) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it('aborts a pending sibling probe and preserves a non-session install-state failure', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - let nodeProbeAborted = false - vi.mocked(resolveRemoteNodePath).mockImplementationOnce((_conn, _host, options) => { - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => { - nodeProbeAborted = true - const abortError = new Error('aborted') - abortError.name = 'AbortError' - reject(abortError) - }) - }) - }) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME - - const timedDeploy = Promise.race([ - deployAndLaunchRelay(conn), - new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error('deploy did not fail promptly')), 100) - }) - ]) - - await expect(timedDeploy).rejects.toThrow(/Remote home is not a valid path/) - expect(nodeProbeAborted).toBe(true) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it.each([false, true])( - 'does not retry after a sibling failure (unconfirmed abort: %s)', - async (unconfirmed) => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign( - new Error('(SSH) Channel open failure: open failed'), - { - reason: 4 - } - ) - const installError = unconfirmed - ? Object.assign(new Error('probe still running'), { - name: 'AbortError', - sshChannelCloseConfirmed: false - }) - : new Error('permission denied while checking relay install') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) - vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - } - ) - - it('lets the surviving probe finish before retrying a refused SSH session', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - let releaseRemoteHome: (home: string) => void = () => {} - let remoteHomeSettled = false - const cancelledProbe = Object.assign(new Error('system SSH probe cancellation unconfirmed'), { - name: 'AbortError', - sshChannelCloseConfirmed: false - }) - mockExecCommand.mockImplementationOnce( - (_conn, _command, options) => - new Promise((resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(cancelledProbe), { once: true }) - releaseRemoteHome = (home: string) => { - remoteHomeSettled = true - resolve(home) - } - }) - ) - vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => Promise.reject(sessionLimitError)) - vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { - if (!remoteHomeSettled) { - throw new Error('Sequential fallback started before first install-state probe settled') - } - return Promise.resolve('/usr/bin/node') - }) - - const deployPromise = deployAndLaunchRelay(conn).catch((error: unknown) => error) - await vi.waitFor(() => expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1)) - await new Promise((resolve) => setImmediate(resolve)) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - - mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - releaseRemoteHome('/home/user') - await expect(deployPromise).resolves.toHaveProperty('transport') - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) - }) - - it('defaults fresh relays to keep-alive-until-reset without rollout artifacts', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn) - - const launchCommand = detachedLaunchCommand(conn) - - expect(launchCommand).toContain(`--grace-time ${DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS}`) - expect(launchCommand).toContain( - "--ripgrep-path '/home/user/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'" - ) - await vi.waitFor(() => - expect(ensureRemoteBundledRipgrep).toHaveBeenCalledWith( - conn, - expect.anything(), - '/home/user', - expect.objectContaining({ signal: expect.anything() }) - ) - ) - expect(launchCommand).not.toContain('--pty-source-credit-v1') - expect(launchCommand).not.toContain('.pty-source-credit-policy') - }) - - it.each([ - [false, false], - [false, true], - [true, false], - [true, true] - ])( - 'waits for ripgrep before cleanup (concurrent exec: %s, upload failure: %s)', - async (concurrent, fails) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - let finishUpload = (): void => {} - vi.mocked(ensureRemoteBundledRipgrep).mockImplementationOnce( - () => - new Promise((resolve, reject) => { - finishUpload = () => (fails ? reject(new Error('upload failed')) : resolve('present')) - }) - ) - await deployAndLaunchRelay(conn) - expect(ensureRemoteBundledRipgrep).toHaveBeenCalledOnce() - const execCount = vi.mocked(execCommand).mock.calls.length - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(concurrent ? 1 : 0) - finishUpload() - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - } - ) - - it.each([false, true])( - 'waits for SQLite setup before cleanup (concurrent exec: %s)', - async (concurrent) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - let finishSetup!: () => void - vi.mocked(ensureRemoteOpenCodeRuntime).mockImplementationOnce( - () => - new Promise((resolve) => { - finishSetup = () => resolve('failed') - }) - ) - await deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce()) - const execCount = vi.mocked(execCommand).mock.calls.length - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - finishSetup() - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - } - ) - - it('does not launch or upload an unprotected binary when recording its reference fails', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - vi.mocked(recordRemoteRipgrepReference).mockResolvedValueOnce(false) - await deployAndLaunchRelay(conn) - expect(detachedLaunchCommand(conn)).not.toContain('--ripgrep-path') - expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() - }) - - it.each([false, true])( - 'skips cleanup after unconfirmed SQLite teardown (concurrent exec: %s)', - async (concurrent) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('teardown-unconfirmed') - await deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce()) - const execCount = vi.mocked(execCommand).mock.calls.length - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() - } - ) - - it('does not launch after an unconfirmed ripgrep reference write', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - const error = Object.assign(new Error('reference write still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(recordRemoteRipgrepReference).mockRejectedValueOnce(error) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect(detachedLaunchCommand(conn)).toBeUndefined() - expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() - }) - - it.each([false, true])( - 'blocks cleanup and runtime retry after uncertain ripgrep teardown (concurrent exec: %s)', - async (concurrent) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - vi.mocked(ensureRemoteBundledRipgrep).mockRejectedValueOnce( - Object.assign(new Error('upload still running'), { sshChannelCloseConfirmed: false }) - ) - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValue('failed') - const deployed = await deployAndLaunchRelay(conn) - await new Promise((resolve) => setImmediate(resolve)) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() - const execCount = vi.mocked(execCommand).mock.calls.length - const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_001) - try { - await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(concurrent ? 1 : 0) - expect(execCommand).toHaveBeenCalledTimes(execCount) - } finally { - clock.mockRestore() - } - } - ) - - it.each([false, true])( - 'gates later cache cleanup and runtime retry on GC termination (confirmed: %s)', - async (confirmed) => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('failed') - vi.mocked(gcOldRelayVersions).mockRejectedValueOnce( - Object.assign(new Error('GC interrupted'), { sshChannelCloseConfirmed: confirmed }) - ) - const deployed = await deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() - const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_001) - try { - await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(confirmed ? 2 : 1) - } finally { - clock.mockRestore() - } - } - ) - - it('allows an unlimited SSH disconnect grace window', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn, undefined, 0, 'target-a') - - const launchCommand = detachedLaunchCommand(conn) - - expect(launchCommand).toContain('--grace-time 0') - expect(launchCommand).not.toContain('--pty-source-credit-v1') - expect(launchCommand).not.toContain('.pty-source-credit-policy') - }) - - it('clamps configured SSH disconnect grace to the seven-day maximum', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn, undefined, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + 1, 'target-a') - - const launchCommand = detachedLaunchCommand(conn) - - expect(launchCommand).toContain(`--grace-time ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}`) - }) - - it('uses a content-hashed versioned remote install directory', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn) - - // The launch + connect commands include the versioned dir path. - const execArgs = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - const allCmds = [...execArgs, ...mockExecCommand.mock.calls.map(([, cmd]) => cmd)] - const sawVersionedDir = allCmds.some((cmd) => - cmd.includes('/.orca-remote/relay-0.1.0+abcdef012345') - ) - expect(sawVersionedDir).toBe(true) - const sawLegacyDir = allCmds.some((cmd) => cmd.includes('relay-v0.1.0')) - expect(sawLegacyDir).toBe(false) - }) - - it('bounds the overall deploy so install + rebuild both fit under the timeout', async () => { - // Why: the outer bound must exceed the worst-case sequential native-deps - // work — a first install (240s) AND a follow-up rebuild (240s) — so a - // legitimate install-then-rebuild is not falsely timed out mid-repair. - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - - // Make the first exec never resolve - mockExecCommand.mockReturnValueOnce(new Promise(() => {})) - - vi.useFakeTimers() - - // Catch the rejection immediately to avoid unhandled rejection warning - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - - // Not timed out yet at the old 300s bound (install + rebuild need more). - await vi.advanceTimersByTimeAsync(301_000) - expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending') - - await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TIMEOUT_MS - 301_000) - expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending') - - await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - - vi.useRealTimers() - }) - - it('aborts a contended install-lock wait at the overall deploy timeout', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) { - return Promise.resolve(`__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0`) - } - return Promise.resolve('') - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) - let lockSignal: AbortSignal | undefined - vi.mocked(acquireInstallLock).mockImplementationOnce((_conn, _dir, _host, options) => { - lockSignal = options?.signal - return new Promise((_resolve, reject) => { - lockSignal?.addEventListener('abort', () => reject(lockSignal?.reason), { once: true }) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(0) - expect(acquireInstallLock).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TIMEOUT_MS) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - expect(lockSignal?.aborted).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('aborts a launch started near the deploy deadline and closes its channel once', async () => { - vi.useFakeTimers() - try { - const launchChannel = { - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - } - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(conn.exec).mockResolvedValue(launchChannel as never) - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockImplementationOnce( - () => - new Promise((resolve) => - setTimeout(() => resolve('ORCA-NATIVE-DEPS-OK'), 899_900) - ) - ) - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('DEAD') - .mockImplementationOnce((_conn, _command, options) => { - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - const error = new Error('SSH operation was cancelled') - error.name = 'AbortError' - reject(error) - }, - { once: true } - ) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(899_900) - expect(conn.exec).toHaveBeenCalledTimes(1) - expect(launchChannel.close).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(100) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - expect(launchChannel.close).toHaveBeenCalledTimes(1) - expect(mockExecCommand).toHaveBeenCalledTimes(6) - await vi.advanceTimersByTimeAsync(10_000) - expect(mockExecCommand).toHaveBeenCalledTimes(6) - } finally { - vi.useRealTimers() - } - }) - - it('uses distinct target-specific relay socket paths', async () => { - const connA = makeMockConnection() - const connB = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockImplementation((_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - if (command.includes('process.stdout.write("READY")')) { - return Promise.resolve('READY') - } - if (command.includes('test -S')) { - return Promise.resolve('DEAD') - } - return Promise.resolve('') - }) - - await deployAndLaunchRelay(connA, undefined, 300, 'target-a') - await deployAndLaunchRelay(connB, undefined, 300, 'target-b') - - const probeCommands = mockExecCommand.mock.calls - .map(([, command]) => command) - .filter( - (command) => - command.includes('test -S') && command.includes('relay-') && command.includes('ALIVE') - ) - expect(probeCommands).toHaveLength(2) - expect(probeCommands[0]).toContain('relay-') - expect(probeCommands[0]).not.toContain('relay.sock') - expect(probeCommands[1]).toContain('relay-') - expect(probeCommands[1]).not.toContain('relay.sock') - expect(probeCommands[0]).not.toEqual(probeCommands[1]) - - const launchA = vi.mocked(connA.exec).mock.calls.at(-1)?.[0] ?? '' - const launchB = vi.mocked(connB.exec).mock.calls.at(-1)?.[0] ?? '' - expect(launchA).toContain('--sock-path') - expect(launchB).toContain('--sock-path') - expect(launchA).not.toEqual(launchB) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts deleted file mode 100644 index b2177a7665c3..000000000000 --- a/src/main/ssh/ssh-relay-deploy.ts +++ /dev/null @@ -1,2641 +0,0 @@ -/* eslint-disable max-lines -- Why: one cohesive contract (version detect, install-locked deploy, native-deps probe, launch, GC); splitting risks install/GC drift. */ -import { existsSync } from 'node:fs' -import { app } from 'electron' -import { relayBundleCandidates } from './relay-bundle-paths' -import { PinnedRelayFallbackError, resolveSshRemoteRuntime } from './ssh-relay-pinned-node' -import { recordSshRelayRuntimeStep } from './ssh-host-node-runtime-mode' -import { isAnsweredHostFailure } from './ssh-relay-host-answered-failure' -import { - ensurePinnedRelayRuntime, - prebuiltRelayNodePath, - verifyPinnedRelayInstall -} from './ssh-relay-pinned-node-install' -import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' -import { - relayRuntimeStepAfterRefusal, - relayRuntimeLadder, - relayRuntimeStorePins, - type RelayRuntimeStep -} from './ssh-relay-runtime-ladder' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError, - sshTargetRelayRuntimeDecisionStore -} from './ssh-relay-runtime-resolution' -import { planRelayRuntimeStep } from './ssh-relay-runtime-step-plan' -import { getSshTargetRegistryStore } from './ssh-target-registry' -import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import type { SshConnection } from './ssh-connection' -import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol' -import type { MultiplexerTransport } from './ssh-channel-multiplexer' -import { - waitForSentinel, - execCommand, - isUnconfirmedSshCommandTermination -} from './ssh-relay-deploy-helpers' -import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' -import { - createRelayInstallMarkerCommand, - createRelayInstallNamespace, - createRelayUploadStageNamespace, - makeRelayInstallDirectoryCommand, - relayHomeRelativeDir, - relaySftpNamespaceMapping, - relayUploadStageSftpNamespaceMapping, - type RelayInstallNamespace, - type RelayUploadStageNamespace -} from './ssh-relay-install-namespace' -import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { - ensureRemoteBundledRipgrep, - remoteRipgrepLayout, - recordRemoteRipgrepReference -} from './ssh-relay-ripgrep-install' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { - createRemoteOpenCodeRuntimeRetry, - type RemoteOpenCodeRuntimePreparation -} from './ssh-relay-opencode-runtime-retry' -import { - readLocalFullVersion, - computeRemoteRelayDir, - isRelayAlreadyInstalled, - finalizeInstall, - abandonInstall, - gcOldRelayVersions -} from './ssh-relay-versioned-install' -import { - attachRelayNativeDepsCache, - promoteRelayNativeDepsCache, - resolveRelayNativeDepsCacheKey, - type RelayNativeDepsCacheContext -} from './ssh-relay-native-deps-cache-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { - releaseRelayGcClaimWithRetry, - tryAcquireRelayGcClaim, - waitForRelayGcClaimRelease -} from './ssh-relay-gc-claim' -import { - NATIVE_DEPS_COMMAND_TIMEOUT_MS, - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, - RELAY_DEPLOY_TIMEOUT_MS -} from './ssh-relay-deploy-timing' -import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' -import { isWindowsRelayPlatform } from '../../shared/relay-artifacts' -import { exportLocalNodeHeadersPrefix, localNodeHeadersFromOutput } from './ssh-relay-node-headers' -import { - probeBuildToolchain, - formatMissingToolchainError, - formatSkippedNodePtyWarning, - shouldProbeBuildToolchainAfterNativeDepsFailure, - formatNodeHeadersDownloadError, - isNodeHeadersDownloadFailure -} from './ssh-relay-build-toolchain' -import { - commandWithNodePath, - makeRemoteExecutableCommand, - readRemoteHomeCommand, - removeRemoteFileCommand -} from './ssh-remote-commands' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - promoteOwnedRelayUploadStageCommand, - recoverOneStaleRelayUploadStageCommand, - relayUploadStagePromotionConfirmed, - RELAY_UPLOAD_STAGE_POOL_NAME, - reserveRelayUploadStageCommand, - type WindowsUploadStageIdentity -} from './ssh-relay-upload-stage-commands' -import { - isWindowsRemoteHost, - joinRemotePath, - normalizeRemoteHome, - validateRemoteHome, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' -import { execHostCommand } from './ssh-relay-host-exec' -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' -import { - classifyWindowsRelayLaunchError, - WINDOWS_RELAY_LAUNCH_LOG_PREFIX, - windowsRelayConnectCommand, - windowsRelayLaunchCommand -} from './ssh-relay-windows-launch-command' -import { - parseWindowsBreakawayLaunchReport, - RELAY_WINDOWS_BREAKAWAY_CONTRACT -} from '../../shared/windows-breakaway-launch' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' -import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' -import { - RelayProbeCleanupUnconfirmedError, - isRelayEndpointHeldError, - isRelayEndpointUnresponsiveError -} from './ssh-relay-endpoint-incumbent' -import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints' -import { - parseShortRelaySocketDir, - remoteSocketPathFitsLimit, - resolveShortRelaySocketDirCommand, - shortRelaySocketPath, - shortRelayVersionSegment, - SHORT_RELAY_SOCKET_DIR_PREFIX -} from './relay-socket-path-limit' -import { isSshSessionLimitError } from './ssh-session-limit-error' -import { - isWindowsRelayPipePath, - relayEndpointForHost, - relayHookEndpointDirForHost, - windowsActivePipeMarkerPath, - windowsRelayFallbackSocketName -} from './ssh-relay-endpoints' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS -} from '../../shared/ssh-types' - -export type RelayDeployResult = { - transport: MultiplexerTransport - serverBuildId?: string - platform: RelayPlatform - hostPlatform?: RemoteHostPlatform - remoteHome?: string - remoteRelayDir?: string - nodePath?: string - sockPath?: string - credentialFile?: string - prepareOpenCodeRuntime?: RemoteOpenCodeRuntimePreparation -} - -class RelayDirectoryGcConflictError extends Error { - constructor( - readonly remoteRelayDir: string, - readonly hostPlatform: RemoteHostPlatform - ) { - super(`Relay directory GC is in progress at ${remoteRelayDir}`) - } -} - -/** - * Deploy the relay to the remote host and launch it, returning the transport (relay's stdin/stdout) for multiplexer use. - */ -export async function deployAndLaunchRelay( - conn: SshConnection, - onProgress?: (status: string) => void, - graceTimeSeconds?: number, - relayInstanceId?: string -): Promise { - let timeoutHandle: ReturnType - const deployAbortController = new AbortController() - const timedOut = Symbol('relay-deploy-timeout') - const deployment = deployAndLaunchRelayInner( - conn, - onProgress, - graceTimeSeconds, - relayInstanceId, - deployAbortController.signal - ).then( - (result) => ({ status: 'fulfilled' as const, result }), - (error: unknown) => ({ status: 'rejected' as const, error }) - ) - const timeoutPromise = new Promise((resolve) => { - timeoutHandle = setTimeout(() => { - deployAbortController.abort() - resolve(timedOut) - }, RELAY_DEPLOY_TIMEOUT_MS) - }) - - try { - const outcome = await Promise.race([deployment, timeoutPromise]) - if (outcome !== timedOut) { - if (outcome.status === 'fulfilled') { - return outcome.result - } - throw outcome.error - } - - const teardownExpired = Symbol('relay-deploy-teardown-timeout') - let teardownTimeoutHandle: ReturnType - const teardown = await Promise.race([ - deployment, - new Promise((resolve) => { - teardownTimeoutHandle = setTimeout( - () => resolve(teardownExpired), - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS - ) - }) - ]).finally(() => clearTimeout(teardownTimeoutHandle!)) - const timeoutError = Object.assign( - new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`), - teardownConfirmation(teardown === teardownExpired ? undefined : teardown) - ) - throw timeoutError - } finally { - clearTimeout(timeoutHandle!) - } -} - -function teardownConfirmation( - outcome: - | { status: 'fulfilled'; result: RelayDeployResult } - | { status: 'rejected'; error: unknown } - | undefined -): { sshChannelCloseConfirmed: boolean; sshTransferTeardownConfirmed: boolean } { - if (!outcome) { - return { sshChannelCloseConfirmed: false, sshTransferTeardownConfirmed: false } - } - if (outcome.status === 'fulfilled') { - return { sshChannelCloseConfirmed: true, sshTransferTeardownConfirmed: true } - } - const error = outcome.error as { - sshChannelCloseConfirmed?: unknown - sshTransferTeardownConfirmed?: unknown - } - return { - sshChannelCloseConfirmed: error?.sshChannelCloseConfirmed === true, - sshTransferTeardownConfirmed: error?.sshTransferTeardownConfirmed === true - } -} - -/** - * Resolve the remote home, derive the versioned relay dir, and check whether the relay is installed there. - * - * Why: extracted to run concurrently with node-path resolution; home and install-check stay sequential because the check needs the resolved dir. - */ -async function resolveRemoteInstallState( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal } -): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> { - // Why: SFTP doesn't expand `~`, so resolve the remote home explicitly via the host's native shell and normalize it. - const remoteHome = normalizeRemoteHome( - await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), { - signal: options?.signal - }), - hostPlatform - ) - // Why: $HOME is only used inside single-quoted shell strings, so validation only rejects control chars — spaces and non-ASCII stay valid. - if (!validateRemoteHome(remoteHome, hostPlatform)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) - const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { - rethrowSessionLimitErrors: options?.rethrowSessionLimitErrors, - signal: options?.signal - }) - return { remoteHome, remoteRelayDir, alreadyInstalled } -} - -type RelayBootstrapState = { - remoteHome: string - remoteRelayDir: string - alreadyInstalled: boolean - nodePath: string -} - -async function resolveRelayBootstrapStateSequentially( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - signal?: AbortSignal -): Promise { - const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform, { signal }) - return { ...installState, nodePath } -} - -async function resolveRelayBootstrapState( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - signal?: AbortSignal -): Promise { - if (!conn.canRunConcurrentExecCommands()) { - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) - } - const abortController = new AbortController() - const abortForDeploy = (): void => abortController.abort() - signal?.addEventListener('abort', abortForDeploy, { once: true }) - if (signal?.aborted) { - abortForDeploy() - } - const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { - rethrowSessionLimitErrors: true, - signal: abortController.signal - }) - const nodePathPromise = resolveRemoteNodePath(conn, hostPlatform, { - rethrowSessionLimitErrors: true, - signal: abortController.signal - }) - try { - const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) - signal?.throwIfAborted() - return { ...installState, nodePath } - } catch (err) { - // Let an admitted probe finish before retrying a refused parallel session. - if (!isSshSessionLimitError(err)) { - abortController.abort() - } - const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) - const unconfirmed = settled.find( - (result) => result.status === 'rejected' && isUnconfirmedSshCommandTermination(result.reason) - ) - if (unconfirmed?.status === 'rejected') { - throw unconfirmed.reason - } - signal?.throwIfAborted() - if (!isSshSessionLimitError(err)) { - throw err - } - const nonSessionFailure = settled.find( - (result) => - result.status === 'rejected' && - !isSshSessionLimitError(result.reason) && - !isAbortError(result.reason) - ) - if (nonSessionFailure?.status === 'rejected') { - throw nonSessionFailure.reason - } - console.warn( - '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' - ) - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) - } finally { - signal?.removeEventListener('abort', abortForDeploy) - } -} - -/** A prebuilt plan already knows its Node, so only home and install state are probed. */ -async function resolvePrebuiltRelayBootstrapState( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - plan: PrebuiltRelayPlan, - signal?: AbortSignal -): Promise { - const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) - return { - ...installState, - nodePath: prebuiltRelayNodePath({ - host: hostPlatform, - remoteRelayDir: installState.remoteRelayDir, - plan - }) - } -} - -function isAbortError(err: unknown): boolean { - return err instanceof Error && err.name === 'AbortError' -} - -/** - * Detect platform, resolve install state + node path, install if absent, launch, and return the transport. - * Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout. - */ -async function deployAndLaunchRelayInner( - conn: SshConnection, - onProgress?: (status: string) => void, - graceTimeSeconds?: number, - relayInstanceId?: string, - deploySignal?: AbortSignal -): Promise { - const target = typeof conn.getTarget === 'function' ? conn.getTarget() : undefined - const registry = getSshTargetRegistryStore() - const ladder = relayRuntimeLadder(resolveSshRemoteRuntime(target)) - const run = new RelayRuntimeLadderRun( - target?.id ?? relayInstanceId ?? '', - target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null, - ladder.length > 1 - ) - let step = ladder[0] ?? 'legacy' - while (true) { - deploySignal?.throwIfAborted() - try { - if (step === 'D') { - if (target) { - recordSshRelayRuntimeStep(target.id, false) - } - run.settle('D') - throw new RemoteRuntimeUnavailableError(run) - } - run.launchStarted = false - const result = await deployAndLaunchRelayAttempt( - conn, - { step, run }, - onProgress, - graceTimeSeconds, - relayInstanceId, - deploySignal - ) - // Why only a laddered pass: a plain host-npm connect has no rung decision to record. - if (run.laddered) { - run.settle(step) - } - if (target) { - recordSshRelayRuntimeStep(target.id, step === 'legacy') - } - return result - } catch (err) { - const refusal = ladderRefusal(err, step, run) - if (refusal) { - console.warn( - `[ssh-relay] Relay runtime rung ${step} unavailable (${refusal.reason}): ${refusal.detail}` - ) - run.refused(step, refusal.reason, refusal.remembered) - step = relayRuntimeStepAfterRefusal(ladder, step, refusal.reason, refusal.remembered) - run.enter(step) - continue - } - if (!(err instanceof RelayDirectoryGcConflictError)) { - if (run.laddered && !deploySignal?.aborted) { - run.unresolved(step) - } - throw err - } - // Why: GC atomically moves the old install aside; wait for its sibling claim to clear, then recompute install state. - await waitForRelayGcClaimRelease(conn, err.remoteRelayDir, err.hostPlatform, deploySignal) - } - } -} - -/** - * The refusal a failed ladder step steps past, or null when it must propagate. Any failure the - * host answered steps down, classified or not; opt-in legacy is the user's choice, with no D below. - */ -function ladderRefusal( - err: unknown, - step: RelayRuntimeStep, - run: RelayRuntimeLadderRun -): PinnedRelayFallbackError | null { - if (step === 'D' || (step === 'legacy' && !run.laddered)) { - return null - } - if (err instanceof PinnedRelayFallbackError) { - return err - } - // Why not after launch: a relay may be running, and a second rung would launch beside it. - if ( - run.launchStarted || - err instanceof RelayDirectoryGcConflictError || - !isAnsweredHostFailure(err) - ) { - return null - } - return new PinnedRelayFallbackError('install_failed', err instanceof Error ? err.message : '') -} - -type RelayRuntimeRequest = { step: RelayRuntimeStep; run: RelayRuntimeLadderRun } - -async function deployAndLaunchRelayAttempt( - conn: SshConnection, - runtimeRequest: RelayRuntimeRequest, - onProgress?: (status: string) => void, - graceTimeSeconds?: number, - relayInstanceId?: string, - deploySignal?: AbortSignal -): Promise { - onProgress?.('Detecting remote platform...') - console.log('[ssh-relay] Detecting remote platform...') - // Why reuse: each ladder step would otherwise re-probe a platform that cannot have changed. - const hostPlatform = - runtimeRequest.run.host ?? (await detectRemoteHostPlatform(conn, { signal: deploySignal })) - if (!hostPlatform) { - throw new Error( - 'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.' - ) - } - const platform = hostPlatform.relayPlatform - console.log(`[ssh-relay] Platform: ${platform}`) - - const localRelayDir = getLocalRelayPath(platform) - if (!localRelayDir) { - throw new Error( - `Relay package for ${platform} not found locally. ` + - `This may be a packaging issue — try reinstalling Orca.` - ) - } - // Why: content-hashed version doubles as remote dir name and wire-handshake version; throws on missing rather than falling back (see docs/ssh-relay-versioned-install-dirs.md). - const baseVersion = readLocalFullVersion(localRelayDir) - const prebuilt = await planRelayRuntimeStep({ - conn, - host: hostPlatform, - baseVersion, - step: runtimeRequest.step, - run: runtimeRequest.run, - signal: deploySignal - }) - try { - return await deployAndLaunchRelayOnRuntime({ - conn, - onProgress, - graceTimeSeconds, - relayInstanceId, - deploySignal, - hostPlatform, - platform, - localRelayDir, - fullVersion: prebuilt?.fullVersion ?? baseVersion, - prebuilt, - run: runtimeRequest.run - }) - } finally { - await prebuilt?.addons.dispose().catch(() => {}) - } -} - -async function deployAndLaunchRelayOnRuntime({ - conn, - onProgress, - graceTimeSeconds, - relayInstanceId, - deploySignal, - hostPlatform, - platform, - localRelayDir, - fullVersion, - prebuilt, - run -}: { - conn: SshConnection - onProgress?: (status: string) => void - graceTimeSeconds?: number - relayInstanceId?: string - deploySignal?: AbortSignal - hostPlatform: RemoteHostPlatform - platform: RelayPlatform - localRelayDir: string - /** Already folded with the runtime digest when `prebuilt` is set (design D8.1). */ - fullVersion: string - prebuilt?: PrebuiltRelayPlan - run: RelayRuntimeLadderRun -}): Promise { - onProgress?.('Checking existing relay...') - // Why: install-check and node resolution are independent; run concurrently to save a round trip, with sequential fallback for restrictive SSH servers. - const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = prebuilt - ? await resolvePrebuiltRelayBootstrapState( - conn, - hostPlatform, - fullVersion, - prebuilt, - deploySignal - ) - : run.hostNodePath - ? // Why: the fallback's strict probe already found this Node; don't resolve it twice. - { - ...(await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { - signal: deploySignal - })), - nodePath: run.hostNodePath - } - : await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) - console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) - console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) - const pinnedContext = prebuilt - ? { - conn, - host: hostPlatform, - remoteRelayDir, - plan: prebuilt, - targetId: run.targetId, - signal: deploySignal, - run - } - : undefined - let uploadStageIdentity: WindowsUploadStageIdentity | undefined - if (pinnedContext?.plan.kind === 'pinned-node') { - onProgress?.('Checking Orca Node runtime...') - await ensurePinnedRelayRuntime({ ...pinnedContext, plan: pinnedContext.plan }, alreadyInstalled) - // Why: once node.exe is verified, stage fencing reads file IDs through it, not Add-Type (D5). - uploadStageIdentity = { node: prebuiltRelayNodePath(pinnedContext) } - } - - // Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace. - const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion) - const uploadStagePoolDir = joinRemotePath( - hostPlatform, - remoteHome, - RELAY_REMOTE_DIR, - RELAY_UPLOAD_STAGE_POOL_NAME - ) - const homeRelativeUploadStagePoolDir = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` - - let ownsInstallLock = false - let launchGcClaimToken: string | undefined - let launchNamespace: RelayInstallNamespace | undefined - if (alreadyInstalled) { - const launchFence = prebuilt - ? await fenceInstalledPinnedRelay( - conn, - remoteRelayDir, - hostPlatform, - homeRelativeRelayDir, - deploySignal - ) - : await repairInstalledNativeDeps( - conn, - remoteRelayDir, - platform, - hostPlatform, - nodePath, - homeRelativeRelayDir, - deploySignal - ) - ownsInstallLock = launchFence.ownsInstallLock - launchGcClaimToken = launchFence.gcClaimToken - launchNamespace = launchFence.sftpNamespace - deploySignal?.throwIfAborted() - } else { - await execHostCommand( - conn, - hostPlatform, - recoverOneStaleRelayUploadStageCommand( - hostPlatform, - uploadStagePoolDir, - undefined, - uploadStageIdentity - ), - { signal: deploySignal } - ) - const uploadStageOwner = createRelayInstallMarkerFileName() - const reservation = await execHostCommand( - conn, - hostPlatform, - reserveRelayUploadStageCommand( - hostPlatform, - uploadStagePoolDir, - uploadStageOwner, - uploadStageIdentity - ), - { signal: deploySignal } - ) - const uploadStage = parseReservedRelayUploadStage( - hostPlatform, - uploadStagePoolDir, - uploadStageOwner, - reservation - ) - const uploadStagePayloadDir = joinRemotePath(hostPlatform, uploadStage.slotDir, 'payload') - const uploadStageNamespace = createRelayUploadStageNamespace( - `${homeRelativeUploadStagePoolDir}/${uploadStage.slotName}`, - uploadStageOwner - ) - const uploadStageSftpNamespace = uploadStageNamespaceIfSupported( - conn, - hostPlatform, - uploadStageNamespace - ) - let uploadStageCleanupAllowed = true - onProgress?.('Uploading relay...') - console.log('[ssh-relay] Uploading relay...') - try { - await uploadRelay( - conn, - platform, - uploadStagePayloadDir, - fullVersion, - hostPlatform, - deploySignal, - { rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace }, - prebuilt?.addons.dir - ) - - await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) - ownsInstallLock = true - try { - // Re-probe after acquiring the lock — a sibling installer may have finished while we waited. - if ( - !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { - signal: deploySignal - })) - ) { - launchNamespace = await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteRelayDir, - homeRelativeRelayDir, - deploySignal - ) - const promotion = await execHostCommand( - conn, - hostPlatform, - promoteOwnedRelayUploadStageCommand( - hostPlatform, - uploadStage, - uploadStageOwner, - remoteRelayDir, - uploadStageIdentity - ), - { signal: deploySignal } - ) - if (!relayUploadStagePromotionConfirmed(uploadStageOwner, promotion)) { - throw new Error('Relay upload stage ownership was lost before promotion') - } - console.log('[ssh-relay] Upload complete') - - if (pinnedContext) { - onProgress?.('Verifying relay runtime...') - await verifyPinnedRelayInstall(pinnedContext) - } else { - onProgress?.('Installing native dependencies...') - console.log('[ssh-relay] Installing native dependencies...') - await installNativeDeps( - conn, - remoteRelayDir, - platform, - hostPlatform, - nodePath, - deploySignal, - [], - launchNamespace, - remoteHome - ) - console.log('[ssh-relay] Native deps installed') - } - - // Why: mark complete but retain the lock until launch makes daemon liveness observable to cross-version GC. - await finalizeInstall(conn, remoteRelayDir, hostPlatform, { - signal: deploySignal, - releaseLock: false - }) - } - } catch (err) { - if (!isUnconfirmedSshCommandTermination(err)) { - await abandonInstall(conn, remoteRelayDir, hostPlatform) - ownsInstallLock = false - } - throw err - } - } catch (error) { - uploadStageCleanupAllowed = !isUnconfirmedSshCommandTermination(error) - throw error - } finally { - if (uploadStageCleanupAllowed) { - await execHostCommand( - conn, - hostPlatform, - cleanupOwnedRelayUploadStageCommand( - hostPlatform, - uploadStage, - uploadStageOwner, - uploadStageIdentity - ) - ).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - } - } - } - - const ripgrepLayout = remoteRipgrepLayout(hostPlatform, remoteHome) - const ripgrepReferenced = - ripgrepLayout && - (await recordRemoteRipgrepReference( - conn, - hostPlatform, - remoteRelayDir, - ripgrepLayout.entryName - )) - deploySignal?.throwIfAborted() - onProgress?.('Starting relay...') - console.log('[ssh-relay] Launching relay...') - // A failed launch retains its fences until stale recovery can establish liveness. - run.launchStarted = true - const launched = await launchRelay( - conn, - remoteRelayDir, - hostPlatform, - nodePath, - graceTimeSeconds, - relayInstanceId, - deploySignal, - ripgrepReferenced ? ripgrepLayout.binaryPath : undefined - ) - let launchCleanupSettled = true - try { - if (ownsInstallLock) { - await abandonInstall(conn, remoteRelayDir, hostPlatform) - } - if (launchGcClaimToken) { - await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) - } - } catch (error) { - // Keep the connected transport, but stop optional commands after uncertain fence release. - launchCleanupSettled = !isUnconfirmedSshCommandTermination(error) - console.warn('[ssh-relay] Launch fence release failed:', error) - } - console.log('[ssh-relay] Relay started successfully') - - // Keep background commands serial for SSH transports that allow only one exec at a time. - const ripgrepEntry = ripgrepLayout?.entryName - const ripgrepInstall = ( - ripgrepReferenced && launchCleanupSettled - ? ensureRemoteBundledRipgrep(conn, hostPlatform, remoteHome, { signal: deploySignal }) - : Promise.resolve() - ).then( - () => launchCleanupSettled, - (error) => !isUnconfirmedSshCommandTermination(error) - ) - const runtimeInstall = ( - conn.canRunConcurrentExecCommands() ? Promise.resolve(launchCleanupSettled) : ripgrepInstall - ) - .then((ripgrepSettled) => - ripgrepSettled - ? ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { - nodePath: launched.nodePath, - verifiedNodePath: uploadStageIdentity?.node, - relayDir: remoteRelayDir, - signal: deploySignal - }) - : ('teardown-unconfirmed' as const) - ) - .catch(() => 'teardown-unconfirmed' as const) - - const cleanupReady = Promise.all([ripgrepInstall, runtimeInstall]).then( - ([ripgrepSettled, runtimeOutcome]) => - ripgrepSettled && runtimeOutcome !== 'teardown-unconfirmed' - ) - const backgroundCleanup = cleanupReady.then((ready) => { - if (!ready) { - return false - } - return ( - execHostCommand( - conn, - hostPlatform, - recoverOneStaleRelayUploadStageCommand( - hostPlatform, - uploadStagePoolDir, - undefined, - uploadStageIdentity - ) - ) - .catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - // Why before GC: a superseded relay pins its version dir via the live-socket probe, so the - // sweep has to settle first or GC keeps every orphan's tree forever. - .then(() => - sweepSupersededRelayEndpoints(conn, hostPlatform, { - remoteHome, - currentRelayDir: remoteRelayDir, - sockName: relaySocketNameForInstanceId(relayInstanceId), - // Set only when this launch relocated past sun_path; the sweep must not reap - // the socket the transport it just handed back is talking to. - ...(launched.sockPath.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX) - ? { - currentShortSocketDir: launched.sockPath.slice( - 0, - launched.sockPath.lastIndexOf('/') - ) - } - : {}), - nodePath: launched.nodePath - }) - ) - .catch((error) => { - if ( - error instanceof RelayProbeCleanupUnconfirmedError || - isUnconfirmedSshCommandTermination(error) - ) { - throw error - } - }) - .then(() => - gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { - windowsNodePath: launched.nodePath, - windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)], - nodePath: launched.nodePath, - // Why pin rather than rely on the symlink alone: a deploy that fell back to a - // per-directory install has no reference to show, and its key must still survive. - nativeDepsCacheKeys: [ - resolveRelayNativeDepsCacheKey({ - platform, - localRelayDir, - deps: RELAY_NATIVE_DEPS - }) - ].filter((key): key is string => key !== null) - }) - ) - // Why after the version GC and not beside it: that pass is what removes the relay directories - // holding the references, so running second is what lets a superseded build become collectable - // in the same connect rather than the next one. - .then(() => - gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry }) - ) - // Same ordering reason as ripgrep: the version pass drops the refs that held old runtimes. - .then(() => - // Why every compat pin too: a rung A connect must not collect the rung B runtime. - prebuilt?.kind === 'pinned-node' - ? gcRemoteNodeRuntimeStore(conn, hostPlatform, remoteHome, { - currentPins: relayRuntimeStorePins(prebuilt.target) - }) - : undefined - ) - .then(() => true) - .catch( - (error) => - !(error instanceof RelayProbeCleanupUnconfirmedError) && - !isUnconfirmedSshCommandTermination(error) - ) - ) - }) - - return { - transport: launched.transport, - serverBuildId: fullVersion, - platform, - hostPlatform, - remoteHome, - remoteRelayDir, - nodePath: launched.nodePath, - sockPath: launched.sockPath, - credentialFile: launched.credentialFile, - prepareOpenCodeRuntime: createRemoteOpenCodeRuntimeRetry( - runtimeInstall, - backgroundCleanup, - (signal) => - ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { - nodePath: launched.nodePath, - verifiedNodePath: uploadStageIdentity?.node, - relayDir: remoteRelayDir, - signal - }) - ) - } -} - -async function uploadRelay( - conn: SshConnection, - platform: RelayPlatform, - remoteDir: string, - fullVersion: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal, - stage?: { rootDir: string; namespace?: RelayUploadStageNamespace }, - /** Pinned-Node addons laid out relative to the relay dir. */ - localAddonsDir?: string -): Promise { - const localRelayDir = getLocalRelayPath(platform) - if (!localRelayDir || !existsSync(localRelayDir)) { - throw new Error( - `Relay package for ${platform} not found. Searched: ${getLocalRelayCandidates(platform).join(', ')}. ` + - `This may be a packaging issue — try reinstalling Orca.` - ) - } - - if (!stage) { - await execHostCommand( - conn, - hostPlatform, - makeRelayInstallDirectoryCommand(hostPlatform, remoteDir), - { signal } - ) - } - - for (const localDir of localAddonsDir ? [localRelayDir, localAddonsDir] : [localRelayDir]) { - await uploadRelayDirectory(conn, localDir, remoteDir, hostPlatform, { - signal, - sftpNamespace: stage?.namespace - ? relayUploadStageSftpNamespaceMapping(stage.namespace, hostPlatform, stage.rootDir) - : undefined - }) - } - - if (!isWindowsRemoteHost(hostPlatform)) { - await execHostCommand( - conn, - hostPlatform, - makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')), - { signal } - ) - } - - // Why: write .version via SFTP not shell to avoid quoting content-hashed versions; the daemon reads it to validate the wire handshake. - await writeRelayFile( - conn, - hostPlatform, - joinRemotePath(hostPlatform, remoteDir, '.version'), - fullVersion, - { - signal, - sftpNamespace: stage?.namespace - ? relayUploadStageSftpNamespaceMapping( - stage.namespace, - hostPlatform, - stage.rootDir, - '.version' - ) - : undefined - } - ) -} - -/** - * A marker is only meaningful where a split namespace can occur and where Orca - * owns the SFTP session: POSIX hosts reached over the bundled ssh2 transport. - */ -function createInstallNamespaceIfSupported( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - homeRelativeRelayDir: string -): RelayInstallNamespace | undefined { - if (isWindowsRemoteHost(hostPlatform)) { - return undefined - } - // A connection double without the transport accessor is an ssh2 connection. - const usesSystemSsh = - typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false - return usesSystemSsh ? undefined : createRelayInstallNamespace(homeRelativeRelayDir) -} - -function uploadStageNamespaceIfSupported( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - namespace: RelayUploadStageNamespace -): RelayUploadStageNamespace | undefined { - if (isWindowsRemoteHost(hostPlatform)) { - return undefined - } - const usesSystemSsh = - typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false - return usesSystemSsh ? undefined : namespace -} - -const NODE_PTY_VERSION = '1.1.0' -const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' -const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs' -const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' -const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' -/** - * Whether the tree the patch left behind still leaks a pty fd -- the master into every later child - * on Linux, a throwaway /dev/ptmx per spawn on macOS. `fixed` is the only outcome a shared cache - * entry may be published from. - */ -type NodePtyMasterCloexecOutcome = 'fixed' | 'unfixed' -/** - * The statuses that leave a non-leaking tree. Deliberately an allowlist, not a `failed:` denylist: - * the script's `skipped:` family is mixed. `skipped:unsupported-platform` is a platform that never - * leaks, but `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:no-prebuild`, - * `skipped:unexpected-source` and the two `skipped:` forms all mean the patch was refused - * and the leaky build is still on disk -- indistinguishable from `failed:` as far as what gets - * published. - */ -const NODE_PTY_CLOEXEC_FIXED_STATUSES: ReadonlySet = new Set([ - 'patched', - // The rebuild ran from patched source; only the leak check could not observe the result. An - // unobservable check is not a failed patch, and treating it as one would disable the shared - // cache on every host without `/proc` or `lsof`. - 'patched-unverified', - 'already-patched', - // Unreachable while the platform gate below short-circuits Windows first, but it is the one - // `skipped:` that means "nothing to fix" rather than "would not fix it". - 'skipped:unsupported-platform' -]) -// Exported for the relay-native-dependency-coverage test, which asserts every -// native addon the relay bundle imports is either installed here or explicitly -// declared as degrading without it. -export const RELAY_NATIVE_DEPS = { - 'node-pty': NODE_PTY_VERSION, - '@parcel/watcher': '2.5.6' -} as const - -type RelayNativeDepName = keyof typeof RELAY_NATIVE_DEPS -const RELAY_NATIVE_DEP_NAMES = Object.keys(RELAY_NATIVE_DEPS) as RelayNativeDepName[] -const NATIVE_DEPS_MISSING_PREFIX = 'ORCA-NATIVE-DEPS-MISSING:' - -// Why: npm 12 blocks dependency lifecycle scripts unless each exact package version is approved, even with ignore-scripts disabled. -const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries( - Object.entries(RELAY_NATIVE_DEPS).map(([name, version]) => [`${name}@${version}`, true]) -) - -function nativeDepsProbeJs(successToken: string): string { - // Why: node-pty's Windows wrapper defers conpty.node until first spawn, so require("node-pty") alone can't prove the binding is healthy. - const loadNodePty = - 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty");' + - `if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd());` + - `require("./${NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME}").assertPatchedNodePtyWindowsTeardown(process.cwd())}` - return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` -} - -/** - * Which deps the probe *named* as unloadable, or `undefined` when the answer names none. - * - * Only the probe's own marker line is evidence about the deps. An answer without one (node never - * ran, was killed, exited before the script) says nothing, so it must not be read as "all of them" — - * that inference deleted both native modules on every reconnect of an affected host. - */ -function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] | undefined { - const marker = output - .split(/\r?\n/) - .find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX)) - if (!marker) { - return undefined - } - const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',') - const named = RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) - return named.length > 0 ? named : undefined -} - -/** - * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered with a marker - * naming deps that failed to load. `unverifiable` — the probe never answered, or answered nothing - * that names a dep; both are evidence about the probe, not about the deps. - * - * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a - * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. An - * unparseable answer is the worse half of that — it is deterministic and per-host, so a node that - * cannot start (bad NODE_OPTIONS, OOM, exit 127) deleted both modules on every reconnect forever. - * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and - * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. - */ -type RelayNativeDepsProbeStatus = 'ok' | 'blocked' | 'unverifiable' - -async function probeRequiredNativeDeps( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { - const escapedNode = shellEscape(nodePath) - const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') - let probeStderr = '' - try { - const command = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` - ) - : // Why: no `2>/dev/null` — it discarded the only line that says why node never reached the - // script. stderr stays its own stream so it can't be mistaken for the verdict, mirroring - // src/main/orcad/node-pty-precondition.ts. - commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `(${escapedNode} -e ${shellEscape(probeJs)} || echo MISSING)` - ) - const probe = await execHostCommand(conn, hostPlatform, command, { - signal, - onStderr: (text) => { - probeStderr = text - } - }) - if (probe.includes('ORCA-NATIVE-DEPS-OK')) { - return { status: 'ok', missing: [] } - } - const missing = missingNativeDepsFromProbe(probe) - if (!missing) { - console.warn( - `[ssh-relay][NATIVE-DEPS-PROBE-UNPARSEABLE] Probe at ${remoteDir} answered without naming a dep; launching as-is. stdout=${probe.trim().slice(-200)} stderr=${probeStderr.trim().slice(-500)}` - ) - return { status: 'unverifiable', missing: [] } - } - return { status: 'blocked', missing } - } catch (error) { - signal?.throwIfAborted() - // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and - // recompiled healthy relays, turning one dropped exec channel into a multi-minute reconnect. - // Why: the wrongful rebuild was the only visible symptom, so without this line a dropped exec - // channel leaves no trace at all. - console.warn( - `[ssh-relay] Native deps probe unanswered at ${remoteDir}; treating as unverifiable: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return { status: 'unverifiable', missing: [] } - } -} - -async function repairInstalledNativeDeps( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - homeRelativeRelayDir: string, - signal?: AbortSignal -): Promise { - const initialProbe = await probeRequiredNativeDeps( - conn, - remoteDir, - hostPlatform, - nodePath, - signal - ) - const { lockResult, gcClaimToken } = await acquireInstalledRelayFence( - conn, - remoteDir, - hostPlatform, - signal - ) - // Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes. - if (initialProbe.status !== 'blocked') { - return launchFenceWithoutRepair( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - { lockResult, gcClaimToken }, - signal - ) - } - - // Why: an already-installed relay can launch degraded, so native-deps repair is best-effort — lock contention and failures must not abort the connection. - console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`) - if (lockResult === 'busy' || lockResult === 'error') { - console.warn( - `[ssh-relay] Native-deps repair lock is ${lockResult} at ${remoteDir}; launching degraded` - ) - return { ownsInstallLock: false, gcClaimToken } - } - try { - // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. - const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - let repairNamespace: RelayInstallNamespace | undefined - if (probe.status !== 'ok') { - // Why: the locked re-probe can only narrow the repair; when it can't answer, the initial probe's answered evidence still stands. - const resetDeps = probe.status === 'unverifiable' ? initialProbe.missing : probe.missing - // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. - repairNamespace = await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - signal - ) - await installNativeDeps( - conn, - remoteDir, - platform, - hostPlatform, - nodePath, - signal, - resetDeps, - repairNamespace - ) - await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) - } - return { ownsInstallLock: true, sftpNamespace: repairNamespace } - } catch (err) { - const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err) - // Why: hold a confirmed-failure lock through degraded launch so GC can't move the relay before liveness is visible. - // Why: unconfirmed remote mutation keeps its stale-recoverable lock beyond this connection. - console.warn( - `[ssh-relay] Native deps repair failed at ${remoteDir}; launching degraded: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return { ownsInstallLock: !terminationUnconfirmed } - } -} - -type RelayLaunchFence = { - ownsInstallLock: boolean - gcClaimToken?: string - sftpNamespace?: RelayInstallNamespace -} - -type InstalledRelayFence = { - lockResult: 'acquired' | 'busy' | 'error' - gcClaimToken?: string -} - -/** Hold an installed relay dir against cross-version GC until launch makes liveness observable. */ -async function acquireInstalledRelayFence( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) - if (lockResult === 'gc') { - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - if (lockResult === 'acquired') { - let stillInstalled: boolean - try { - stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { - rethrowSessionLimitErrors: true, - signal - }) - } catch (err) { - if (!isUnconfirmedSshCommandTermination(err)) { - await abandonInstall(conn, remoteDir, hostPlatform) - } - throw err - } - if (!stillInstalled) { - // Why: GC may finish its rename before our lock recreates the path; never trust probes made before this locked recheck. - await abandonInstall(conn, remoteDir, hostPlatform) - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - } - const gcClaimToken = - lockResult === 'busy' || lockResult === 'error' - ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) - : undefined - return { lockResult, gcClaimToken } -} - -async function launchFenceWithoutRepair( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - remoteDir: string, - homeRelativeRelayDir: string, - { lockResult, gcClaimToken }: InstalledRelayFence, - signal?: AbortSignal -): Promise { - // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. - if (lockResult !== 'acquired') { - return { ownsInstallLock: false, gcClaimToken } - } - try { - return { - ownsInstallLock: true, - sftpNamespace: await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - signal - ) - } - } catch (err) { - signal?.throwIfAborted() - console.warn( - `[ssh-relay] Launch namespace marker is unconfirmed at ${remoteDir}; deferring lock ownership to stale recovery` - ) - return { ownsInstallLock: !isUnconfirmedSshCommandTermination(err) } - } -} - -/** A pinned-Node relay dir has no host-installed native deps to repair; only the launch fence applies. */ -async function fenceInstalledPinnedRelay( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - homeRelativeRelayDir: string, - signal?: AbortSignal -): Promise { - const fence = await acquireInstalledRelayFence(conn, remoteDir, hostPlatform, signal) - return launchFenceWithoutRepair( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - fence, - signal - ) -} - -/** - * Stamp this connection as the launch writer while it owns the install lock. - * Confirmed marker failures fall back to shell-path credential generation. - */ -async function createRelayLaunchNamespace( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - remoteDir: string, - homeRelativeRelayDir: string, - signal?: AbortSignal -): Promise { - const namespace = createInstallNamespaceIfSupported(conn, hostPlatform, homeRelativeRelayDir) - if (!namespace) { - return undefined - } - try { - await execHostCommand( - conn, - hostPlatform, - createRelayInstallMarkerCommand(namespace, hostPlatform, remoteDir), - { signal } - ) - return namespace - } catch (err) { - // Why: an unconfirmed termination still owes the caller its lock semantics; only a confirmed failure degrades to shell paths. - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - console.warn( - `[ssh-relay] SFTP namespace marker unavailable at ${remoteDir}; retaining shell paths` - ) - return undefined - } -} - -async function acquireRelayLaunchGcFence( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const token = await tryAcquireRelayGcClaim(conn, remoteDir, hostPlatform, signal) - if (!token) { - signal?.throwIfAborted() - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - try { - signal?.throwIfAborted() - const stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { - rethrowSessionLimitErrors: true, - signal - }) - if (!stillInstalled) { - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - // Why: a caller without the install lock still needs its own durable fence; never borrow another connection's lock through launch. - return token - } catch (err) { - if (!isUnconfirmedSshCommandTermination(err)) { - await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) - } - throw err - } -} - -// Why: node-pty and @parcel/watcher are native addons esbuild can't bundle; install them on the remote against its Node/OS. -// TODO(#1693): ship per-platform tarballs with node-pty prebuilt from CI to skip remote npm install. -async function installNativeDeps( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal, - resetDeps: RelayNativeDepName[] = [], - namespace?: RelayInstallNamespace, - remoteHome?: string -): Promise { - // Why a repair opts out: reset does `rm -rf node_modules/node-pty`, and through a shared - // symlink that is every relay on the host losing its addon. Repairs detach and install - // privately instead (the install command's own prefix drops the link). - const localRelayDir = resetDeps.length === 0 && remoteHome ? getLocalRelayPath(platform) : null - const cacheContext: RelayNativeDepsCacheContext | null = - remoteHome && localRelayDir - ? { - hostPlatform, - remoteHome, - relayDir: remoteDir, - platform, - localRelayDir, - deps: RELAY_NATIVE_DEPS, - signal - } - : null - const cache = cacheContext ? await attachRelayNativeDepsCache(conn, cacheContext) : null - - const writeRelayPackageJson = async (deps: Record): Promise => { - await writeRelayFile( - conn, - hostPlatform, - joinRemotePath(hostPlatform, remoteDir, 'package.json'), - `${JSON.stringify({ - name: 'orca-relay', - version: '1.0.0', - private: true, - type: 'commonjs', - dependencies: deps, - allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST - })}\n`, - { - signal, - sftpNamespace: namespace - ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json') - : undefined - } - ) - } - - // Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127. - // Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead. - // Why: type:commonjs pins module resolution against Node default flips or a remote ~/.npmrc type=module. - await writeRelayPackageJson(RELAY_NATIVE_DEPS) - - if (cache?.mode === 'linked') { - await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) - const linkedProbe = await probeInstalledNativeDeps( - conn, - remoteDir, - hostPlatform, - nodePath, - signal - ) - if (linkedProbe.available) { - return - } - // Why fall through rather than repair the entry: it is shared, and something else on this - // host may be running out of it right now. This directory installs its own copy instead. - console.warn( - `[ssh-relay][NATIVE-CACHE-UNUSABLE] shared entry ${cache.key} did not load at ${remoteDir} (${platform}); installing per-directory. stderr=${linkedProbe.stderr.trim().slice(-500)}` - ) - } - - try { - const installArgs = Object.entries(RELAY_NATIVE_DEPS) - .map(([dep, version]) => shellEscape(`${dep}@${version}`)) - .join(' ') - // Why: npm reports a present package as up to date even if a native file was deleted; reset only deps the probe found broken. - const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) - const resetPrefix = `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand ? `${resetCommand}; ` : ''}` - const command = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${Object.entries( - RELAY_NATIVE_DEPS - ) - .map(([dep, version]) => powerShellLiteral(`${dep}@${version}`)) - .join( - ' ' - )}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}` - ) - : commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${exportLocalNodeHeadersPrefix(nodePath)}${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` - ) - await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, - signal - }) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - // Don't write .install-complete on hard fail so reconnect retries the partial install; greppable token aids bug reports. - const msg = (err as Error).message - console.warn( - `[ssh-relay][NATIVE-DEPS-INSTALL-FAIL] npm install native deps failed at ${remoteDir} (${platform}): ${msg}` - ) - // Why: on Linux node-pty compiles, so a missing C/C++ toolchain is the dominant first-connect failure (#1693); probe to give an actionable install hint. - if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) { - const toolchain = await probeBuildToolchain(conn, hostPlatform, signal) - if (toolchain?.toolchainMissing) { - // Why: node-pty is the only dep that needs a compiler, and it only backs terminals. Retry - // without it so files/git/editor still connect instead of failing the host outright; a - // missing native dep is already non-fatal below. Rethrow the actionable error if even that - // fails, so a host broken for some other reason still reports the toolchain gap. - console.warn( - `[ssh-relay][NPTY-SKIP-NO-TOOLCHAIN] ${remoteDir} (${platform}): ${formatSkippedNodePtyWarning(toolchain)}` - ) - try { - await installNativeDepsWithoutNodePty( - conn, - remoteDir, - hostPlatform, - nodePath, - writeRelayPackageJson, - resetDeps, - signal - ) - } catch (retryErr) { - if (isUnconfirmedSshCommandTermination(retryErr)) { - throw retryErr - } - signal?.throwIfAborted() - // The thrown toolchain message is built from the original error, so log the retry's own - // cause (registry, ENOSPC, EACCES) rather than losing it. - console.warn( - `[ssh-relay][NPTY-SKIP-RETRY-FAIL] node-pty-less reinstall failed at ${remoteDir} (${platform}): ${(retryErr as Error).message}` - ) - throw new Error(formatMissingToolchainError(toolchain, msg), { cause: retryErr }) - } - // Why: this early return skips the probe below, so verify the dep that does have a prebuilt — - // a @parcel/watcher that installs but can't load would leave file watching silently dead. - await warnIfWatcherUnloadableWithoutNodePty( - conn, - remoteDir, - platform, - hostPlatform, - nodePath, - signal - ) - return - } - } - // Why: either the local-headers export found nothing (a host both header-less and offline) or - // it did and node-gyp downloaded anyway (the export is broken) -- name which, or the log reads - // as a broken relay either way. - if (platform.startsWith('linux') && isNodeHeadersDownloadFailure(msg)) { - throw new Error(formatNodeHeadersDownloadError(msg, localNodeHeadersFromOutput(msg)), { - cause: err - }) - } - throw err - } - - await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) - - let probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - if (!probe.available) { - // Why: npm treats an already-present package as up to date, so re-enabling lifecycle scripts on install can't repair a skipped binding. - console.warn(`[ssh-relay] Rebuilding unloadable native deps at ${remoteDir}`) - let rebuilt = false - try { - await rebuildNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - rebuilt = true - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - // Same diagnosis as the install catch: this fallback is non-fatal, so the log is the only - // place the offline-headers cause can reach anyone. - const rebuildMsg = (err as Error).message - console.warn( - `[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${ - platform.startsWith('linux') && isNodeHeadersDownloadFailure(rebuildMsg) - ? formatNodeHeadersDownloadError(rebuildMsg, localNodeHeadersFromOutput(rebuildMsg)) - : rebuildMsg - }` - ) - } - signal?.throwIfAborted() - if (rebuilt) { - await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) - probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - } - } - - // Why this precedes promotion: the patch renames `node-pty/build/Release`, runs `npm rebuild` - // and rolls back inside `node_modules`, and promotion turns that directory into a symlink to a - // published -- and by contract immutable -- shared cache entry. Patching afterwards would write - // through the link, and `.deps-complete` would already have published an unpatched tree that - // every later host links and skips. - const cloexec = probe.available - ? await applyNodePtyMasterCloexecPatch( - conn, - remoteDir, - platform, - hostPlatform, - nodePath, - signal - ) - : 'unfixed' - - // Why promotion is gated on the probe and not on npm's exit code: an entry is shared, so the - // only evidence worth publishing is this host having loaded both addons out of that tree. - // Why it is gated on the patch too: a refused or rolled-back patch leaves the pre-patch leaky - // build in place, and the cache key hashes this patch's bytes -- so publishing it would hand - // every later host on the machine a tree that links, probes loadable, and skips patching. - if (probe.available && cacheContext && cache) { - if (cloexec === 'fixed') { - await promoteRelayNativeDepsCache(conn, cacheContext, cache.key) - } else { - console.warn( - `[ssh-relay][NPTY-CLOEXEC-UNSHARED] keeping the native deps at ${remoteDir} (${platform}) private; the tree still leaks the pty master, so it is not publishable as ${cache.key}` - ) - } - } - - // MISSING is non-fatal by design: the relay still serves fs/git/preflight; only native-backed ops fail on hosts that can't build the addons. - if (!probe.available) { - console.warn( - `[ssh-relay][NPTY-MISSING] native deps installed but require() failed at ${remoteDir} (${platform}). stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` - ) - } -} - -/** - * Re-apply the pty fd-leak patch the app gets from pnpm to the host's npm copy (#17915). - * - * Why it is safe to rebuild under a live relay: this only runs from installNativeDeps, so only on a - * freshly created directory or a locked repair, and a relay already serving PTYs has pty.node mapped - * -- replacing the file on disk does not touch the running process. It keeps the build it started - * with and picks up the patched one when it restarts. - * - * Why it is bounded: the remote script attempts the compile at most once per relay directory, and - * the directory is content-hashed over the relay manifest -- so at most one compile per bundle. - * - * Why a shared cache entry never reaches here: the caller returns as soon as a linked tree probes - * loadable, so this only ever rewrites a `node_modules` the relay directory still owns privately. - * - * Returns whether the tree that is left behind still leaks, which is what decides publishability. - * The script exits 0 on every outcome by design, so the status line is the only evidence there is. - */ -async function applyNodePtyMasterCloexecPatch( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise { - // Both Unix relay platforms leak the pty master, by different bugs: Linux inherits it through - // forkpty()'s no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in - // pty_posix_spawn. Windows is short-circuited because it has no fds for a master to leak into -- - // and answering 'fixed' from a gate that ran nothing is exactly how a leaking darwin tree got - // published to the shared cache. - // - // What 'fixed' means here is exactly "this tree does not leak the pty MASTER", which is the only - // thing the shared native-deps cache keys on. It is NOT a statement that a Windows relay leaks - // nothing: it leaked one Windows File handle per terminal until the ConPTY teardown patch above, - // by a mechanism that has nothing to do with fds. Read this gate as scoped to its own question. - if (isWindowsRemoteHost(hostPlatform) || isWindowsRelayPlatform(platform)) { - return 'fixed' - } - try { - const command = commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${exportLocalNodeHeadersPrefix(nodePath)}${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1` - ) - const output = await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, - signal - }) - const status = - output - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.startsWith(NODE_PTY_CLOEXEC_STATUS_PREFIX)) - ?.slice(NODE_PTY_CLOEXEC_STATUS_PREFIX.length) ?? 'no-status' - if (!NODE_PTY_CLOEXEC_FIXED_STATUSES.has(status)) { - // Warn, not log: the script exits 0 on a refusal too, so this line is the only thing that - // says the relay directory will leak a master into every child for its whole life. - console.warn( - `[ssh-relay][NPTY-CLOEXEC-UNFIXED] pty master still leaks at ${remoteDir} (${platform}): ${status}` - ) - return 'unfixed' - } - console.log(`[ssh-relay][NPTY-CLOEXEC] ${remoteDir} (${platform}): ${status}`) - return 'fixed' - } catch (err) { - signal?.throwIfAborted() - // Never fatal: the script restores the working build itself, and a leaky relay beats none. An - // interrupted rebuild leaves node-pty unloadable, which the existing repair path reinstalls. - console.warn( - `[ssh-relay][NPTY-CLOEXEC-FAIL] pty master cloexec patch failed at ${remoteDir} (${platform}): ${(err as Error).message}` - ) - // An exec that never answered cannot say which build is on disk, and a tree nobody can vouch - // for is exactly the one not to share. - return 'unfixed' - } -} - -/** - * Drop a shared-cache symlink before anything writes into `node_modules`. - * - * Why it prefixes every install rather than living in its own exec: `rm -rf node_modules/node-pty` - * and `npm install` both follow the link, so a repair on one relay directory would otherwise - * rewrite the tree every other relay on the host is running out of. - */ -function detachSharedNativeDepsCommand(hostPlatform: RemoteHostPlatform): string { - if (isWindowsRemoteHost(hostPlatform)) { - return '' - } - return 'if [ -L node_modules ]; then rm -f node_modules; fi; ' -} - -function resetNativeDepsCommand( - hostPlatform: RemoteHostPlatform, - resetDeps: RelayNativeDepName[] -): string { - if (resetDeps.length === 0) { - return '' - } - const resetNodePty = resetDeps.includes('node-pty') - const resetWatcher = resetDeps.includes('@parcel/watcher') - if (isWindowsRemoteHost(hostPlatform)) { - const commands: string[] = [] - if (resetNodePty) { - commands.push( - `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/node-pty')} -Recurse -Force -ErrorAction SilentlyContinue` - ) - } - if (resetWatcher) { - commands.push( - `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/@parcel/watcher')} -Recurse -Force -ErrorAction SilentlyContinue`, - `$parcelScope = ${powerShellLiteral('node_modules/@parcel')}`, - `Get-ChildItem -LiteralPath $parcelScope -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith('watcher-', [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue` - ) - } - return commands.join('; ') - } - const commands: string[] = [] - if (resetNodePty) { - commands.push(`rm -rf ${shellEscape('node_modules/node-pty')}`) - } - if (resetWatcher) { - commands.push( - `rm -rf ${shellEscape('node_modules/@parcel/watcher')}`, - `find ${shellEscape('node_modules/@parcel')} -maxdepth 1 -name 'watcher-*' -exec rm -rf {} + 2>/dev/null || true` - ) - } - return commands.join('; ') -} - -/** - * Reinstall the relay's native deps with node-pty dropped, for hosts that cannot compile it. - * - * Why: npm reconciles every dependency in package.json, not just the ones named on the command - * line, so node-pty has to leave the manifest too — naming only @parcel/watcher still rebuilds it. - */ -async function installNativeDepsWithoutNodePty( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - writeRelayPackageJson: (deps: Record) => Promise, - resetDeps: RelayNativeDepName[], - signal?: AbortSignal -): Promise { - const deps = Object.fromEntries( - Object.entries(RELAY_NATIVE_DEPS).filter(([dep]) => dep !== 'node-pty') - ) - await writeRelayPackageJson(deps) - const installArgs = Object.entries(deps) - .map(([dep, version]) => shellEscape(`${dep}@${version}`)) - .join(' ') - // Why: the failed attempt leaves an unbuildable node-pty behind; clear it so npm prunes rather - // than rebuilds it. Keep the caller's resets too — a repair reconnect still needs them. - const resetCommand = resetNativeDepsCommand(hostPlatform, [ - ...new Set([...resetDeps, 'node-pty']) - ]) - // Why: POSIX-only command shape (`;` chaining, `2>&1`) — safe because the only caller is gated on a - // linux platform. Widen that gate and this needs the Windows branch installNativeDeps already has. - await execHostCommand( - conn, - hostPlatform, - commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` - ), - { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal } - ) -} - -/** - * Report an unloadable @parcel/watcher after node-pty was skipped, without failing the connection. - * - * Why: node-pty is expected missing here, but a @parcel/watcher prebuilt that installs and still - * can't require() (glibc below the floor — docs/reference/linux-glibc-compatibility.md) means dead - * file watching. No rebuild: node-pty provably can't compile on this host, so it would only fail. - */ -async function warnIfWatcherUnloadableWithoutNodePty( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise { - try { - const probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - if (probe.missing.includes('@parcel/watcher')) { - console.warn( - `[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED] @parcel/watcher installed but require() failed at ${remoteDir} (${platform}); remote file watching is unavailable. stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` - ) - } - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - // Degraded-mode diagnostics must never cost the connection the retry just salvaged. - console.warn( - `[ssh-relay][WATCHER-PROBE-FAIL] native deps probe failed after skipping node-pty at ${remoteDir} (${platform}): ${(err as Error).message}` - ) - } -} - -async function rebuildNativeDeps( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise { - const depNames = Object.keys(RELAY_NATIVE_DEPS) - const command = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}` - ) - : commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${exportLocalNodeHeadersPrefix(nodePath)}npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1` - ) - await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, - signal - }) -} - -function windowsNodePtyPatchCommand(nodePath: string): string { - // Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallbacks to the remote npm package. - return [ - `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`, - `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` - ].join('; ') -} - -async function makeNodePtySpawnHelperExecutable( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - if (isWindowsRemoteHost(hostPlatform)) { - return - } - // SFTP doesn't preserve execute bits; node-pty's spawn-helper must be +x for posix_spawnp. - await execHostCommand( - conn, - hostPlatform, - `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`, - { signal } - ) -} - -async function probeInstalledNativeDeps( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise<{ - available: boolean - missing: RelayNativeDepName[] - output: string - stderr: string -}> { - // require() catches unloadable installs (wrong arch, missing prebuild, skipped lifecycle script) that require.resolve() and test -d miss. - const PROBE_OK = 'ORCA-NPTY-PROBE-OK' - const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') - const escapedStderr = shellEscape(stderrFile) - const probeJs = nativeDepsProbeJs(PROBE_OK) - const probeCommand = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` - ) - : commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `(${shellEscape(nodePath)} -e ${shellEscape(probeJs)} ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` - ) - const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand, { signal }) - const remoteStderr = - probeOutput.includes(PROBE_OK) || isWindowsRemoteHost(hostPlatform) - ? '' - : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`, { - signal - }).catch(() => '') - signal?.throwIfAborted() - if (!isWindowsRemoteHost(hostPlatform)) { - // The POSIX probe redirects stderr to this file; the Windows probe does not. - await execHostCommand(conn, hostPlatform, removeRemoteFileCommand(hostPlatform, stderrFile), { - signal - }).catch(() => {}) - signal?.throwIfAborted() - } - return { - available: probeOutput.includes(PROBE_OK), - // A markerless answer names no dep, so it reports none; `available` already carries the failure. - missing: probeOutput.includes(PROBE_OK) ? [] : (missingNativeDepsFromProbe(probeOutput) ?? []), - output: probeOutput, - stderr: remoteStderr - } -} - -function getLocalRelayPath(platform: RelayPlatform): string | null { - for (const candidate of getLocalRelayCandidates(platform)) { - if (existsSync(candidate)) { - return candidate - } - } - return null -} - -export function getLocalRelayCandidates(platform: RelayPlatform): string[] { - return relayBundleCandidates(platform, app.getAppPath()) -} - -async function launchRelay( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - graceTimeSeconds?: number, - relayInstanceId?: string, - signal?: AbortSignal, - ripgrepPath?: string -): Promise<{ - transport: MultiplexerTransport - nodePath: string - sockPath: string - credentialFile: string -}> { - // Why: graceTimeSeconds comes from user-editable SshTarget config; floor+clamp to an integer prevents shell injection if the type ever loosened. - const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) - const graceTime = - requestedGraceTime === 0 - ? 0 - : Math.max( - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requestedGraceTime) - ) - const escapedDir = shellEscape(remoteDir) - const escapedNode = shellEscape(nodePath) - // Why: remoteRelayDir is shared across Orca targets for one account; hashing the target ID into the socket name stops cross-target attach. - const sockName = relaySocketNameForInstanceId(relayInstanceId) - const defaultSockFile = relayEndpointForHost(hostPlatform, remoteDir, sockName) - const endpointDir = relayHookEndpointDirForHost(hostPlatform, remoteDir, defaultSockFile) - const credentialFile = joinRemotePath(hostPlatform, remoteDir, `${sockName}.credential`) - // Why: a long remote $HOME pushes the default endpoint past sun_path and bind fails with a bare `listen EINVAL` (#10726). - const sockFile = remoteSocketPathFitsLimit(hostPlatform, defaultSockFile) - ? defaultSockFile - : await resolveShortPosixRelaySocketPath(conn, remoteDir, sockName, defaultSockFile, signal) - - if (isWindowsRemoteHost(hostPlatform)) { - const activePipeMarkerPath = windowsActivePipeMarkerPath(hostPlatform, remoteDir, sockName) - const discoveredActiveEndpoint = await readWindowsActiveRelayEndpoint( - conn, - hostPlatform, - remoteDir, - activePipeMarkerPath, - signal - ) - const activeEndpoint = discoveredActiveEndpoint ?? { - sockPath: sockFile, - endpointDir - } - const fallbackEndpoint = buildWindowsRelayFallbackEndpoint(hostPlatform, remoteDir, sockName) - const launched = await launchWindowsRelay( - conn, - hostPlatform, - { - remoteDir, - nodePath, - sockPath: activeEndpoint.sockPath, - endpointDir: activeEndpoint.endpointDir, - graceTime, - activePipeMarkerPath, - reconnectFallback: fallbackEndpoint, - credentialFile, - ripgrepPath - }, - signal - ) - return { ...launched, credentialFile } - } - - // Why: after a restart the relay may still be alive in its grace period; --connect to its socket preserves PTY state and scrollback. - try { - const probeOutput = await execCommand( - conn, - `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD`, - { signal } - ) - console.warn(`[ssh-relay] Socket probe result: "${probeOutput.trim()}"`) - if (probeOutput.trim() === 'ALIVE') { - console.log('[ssh-relay] Existing relay socket found, attempting reconnect...') - try { - const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)}`, - { signal } - ) - const transport = await waitForSentinel(channel, signal) - console.log('[ssh-relay] Reconnected to existing relay via socket') - return { transport, nodePath, sockPath: sockFile, credentialFile } - } catch (err) { - signal?.throwIfAborted() - console.warn( - '[ssh-relay] Socket reconnect failed, establishing what owns the endpoint:', - err instanceof Error ? err.message : String(err) - ) - // Why not `rm -f`: unlinking does not close the listener the incumbent already holds, - // so a refused --connect (version mismatch, rotated credential) used to leave a live - // relay running forever with its PTYs while a replacement bound the same path (#8585). - await resolveRelayEndpointBeforeRelaunch(conn, hostPlatform, nodePath, sockFile, err, { - signal - }) - signal?.throwIfAborted() - } - } - } catch (err) { - // Why rethrow the verdicts: this catch predates the incumbent probe and was meant for a failed - // `test -S`. Swallowing a Held/Unresponsive verdict launches a fresh daemon over a live one — - // the exact collision the probe exists to prevent (it lost the bind, but only by luck). - if ( - err instanceof RelayProbeCleanupUnconfirmedError || - isUnconfirmedSshCommandTermination(err) || - isRelayEndpointHeldError(err) || - isRelayEndpointUnresponsiveError(err) - ) { - throw err - } - signal?.throwIfAborted() - // Probe failed — fall through to fresh launch - } - - // Why: relay must outlive the SSH connection so PTY sessions survive app restarts — nohup + ${shellEscape(logFile)} 2>&1 {}) - launchChannel.on('error', () => {}) - launchChannel.stderr.on('data', () => {}) - launchChannel.stderr.on('error', () => {}) - // Why: the SSH channel stays open until all child fds close; close it after the poll or channels accumulate and hit the server's MaxSessions limit. - launchChannel.on('close', () => {}) - - // Why: poll rather than fixed sleep — remote host speed varies widely (CI vs. Raspberry Pi). - // Why: test -S only proves the inode exists, not that the relay is listening; a connect-and-close confirms it accepts connections. - const POLL_INTERVAL_MS = 200 - const POLL_TIMEOUT_MS = 10_000 - const pollStart = Date.now() - let socketReady = false - try { - while (Date.now() - pollStart < POLL_TIMEOUT_MS) { - try { - // Why: probe via node (guaranteed present) not python3/socat/perl; pass the socket path as argv[1] to dodge -e quoting issues. - const result = await execCommand( - conn, - `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, - { signal } - ) - if (result.trim() === 'READY') { - socketReady = true - break - } - } catch { - signal?.throwIfAborted() - /* exec failed, retry */ - } - await waitForRelayPoll(POLL_INTERVAL_MS, signal) - } - } finally { - launchChannel.close() - } - - if (!socketReady) { - const logOutput = await execCommand( - conn, - `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"`, - { signal } - ).catch(() => '(could not read log)') - signal?.throwIfAborted() - throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) - } - - // Why: backgrounded relay's stdout goes to a log file, not the exec channel; --connect bridges this channel to its Unix socket. - const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)}`, - { signal } - ) - return { - transport: await waitForSentinel(channel, signal), - nodePath, - sockPath: sockFile, - credentialFile - } -} - -/** - * Move the endpoint under a `$HOME`-independent base so its length is bounded. - * - * The hashed socket name is preserved in full: only the directory shrinks, so the - * short form stays deterministic per target and cannot collide with another target. - * The version directory's identity comes along as a hashed segment, so a later build - * still binds a path of its own rather than the one its predecessor is holding. - */ -async function resolveShortPosixRelaySocketPath( - conn: SshConnection, - remoteDir: string, - sockName: string, - defaultSockFile: string, - signal?: AbortSignal -): Promise { - const versionSegment = shortRelayVersionSegment(remoteDir.slice(remoteDir.lastIndexOf('/') + 1)) - const output = await execCommand(conn, resolveShortRelaySocketDirCommand(versionSegment), { - signal - }).catch((err: unknown) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - return '' - }) - const shortDir = parseShortRelaySocketDir(output, versionSegment) - if (!shortDir) { - throw new Error( - `Relay socket path ${defaultSockFile} exceeds the remote Unix socket limit and no short socket directory could be created on the host.` - ) - } - const shortSockFile = shortRelaySocketPath(shortDir, sockName) - console.warn( - `[ssh-relay] Socket path too long for sun_path; using ${shortSockFile} instead of ${defaultSockFile}` - ) - return shortSockFile -} - -function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const onAbort = (): void => { - clearTimeout(timeout) - signal?.removeEventListener('abort', onAbort) - reject(createSshOperationAbortError()) - } - const timeout = setTimeout(() => { - signal?.removeEventListener('abort', onAbort) - resolve() - }, delayMs) - signal?.addEventListener('abort', onAbort, { once: true }) - if (signal?.aborted) { - onAbort() - } - }) -} - -function buildWindowsRelayFallbackEndpoint( - hostPlatform: RemoteHostPlatform, - remoteDir: string, - sockName: string -): WindowsRelayEndpoint { - const fallbackSockName = windowsRelayFallbackSocketName(sockName) - const sockPath = relayEndpointForHost(hostPlatform, remoteDir, fallbackSockName) - return { - sockPath, - endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath) - } -} - -async function readWindowsActiveRelayEndpoint( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - remoteDir: string, - markerPath: string, - signal?: AbortSignal -): Promise { - const output = await execHostCommand( - conn, - hostPlatform, - powerShellCommand( - `if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }` - ), - { signal } - ).catch(() => { - signal?.throwIfAborted() - return '' - }) - const sockPath = output.trim() - if (!isWindowsRelayPipePath(sockPath)) { - return null - } - return { - sockPath, - endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath) - } -} - -async function rememberWindowsActiveRelayEndpoint( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - markerPath: string, - sockPath: string, - signal?: AbortSignal -): Promise { - await execHostCommand( - conn, - hostPlatform, - powerShellCommand( - `Set-Content -LiteralPath ${powerShellLiteral(markerPath)} -Value ${powerShellLiteral(sockPath)} -NoNewline` - ), - { signal } - ).catch((err) => { - signal?.throwIfAborted() - // Why: fallback pipe names are deterministic, so losing this marker won't orphan an undiscoverable relay. - console.warn( - `[ssh-relay] Failed to persist Windows active relay pipe at ${markerPath}: ${err instanceof Error ? err.message : String(err)}` - ) - }) -} - -type WindowsRelayEndpoint = { - sockPath: string - endpointDir: string -} - -type WindowsRelayLaunchOptions = { - remoteDir: string - nodePath: string - graceTime: number - activePipeMarkerPath: string - credentialFile: string - ripgrepPath?: string -} & WindowsRelayEndpoint & { - reconnectFallback?: WindowsRelayEndpoint - } - -async function launchWindowsRelay( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: WindowsRelayLaunchOptions, - signal?: AbortSignal -): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { - let launchOpts = opts - if ((await probeWindowsRelayPipe(conn, hostPlatform, opts, signal)) === 'READY') { - try { - const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal) - await rememberWindowsActiveRelayEndpoint( - conn, - hostPlatform, - opts.activePipeMarkerPath, - opts.sockPath, - signal - ) - return { - transport, - nodePath: opts.nodePath, - sockPath: opts.sockPath - } - } catch (err) { - signal?.throwIfAborted() - console.warn( - '[ssh-relay] Windows named pipe reconnect failed, launching fresh relay:', - err instanceof Error ? err.message : String(err) - ) - if (opts.reconnectFallback) { - // Why: a Windows named pipe can't be unlinked like a Unix socket; a deterministic fallback pipe keeps the next deploy recoverable. - // Why: spread keeps activePipeMarkerPath at the original target sock name — the marker records that target's active pipe, fallback or not. - launchOpts = { ...opts, ...opts.reconnectFallback } - } - } - } - - if ( - launchOpts !== opts && - (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts, signal)) === 'READY' - ) { - try { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) - await rememberWindowsActiveRelayEndpoint( - conn, - hostPlatform, - launchOpts.activePipeMarkerPath, - launchOpts.sockPath, - signal - ) - return { - transport, - nodePath: launchOpts.nodePath, - sockPath: launchOpts.sockPath - } - } catch (err) { - signal?.throwIfAborted() - console.warn( - '[ssh-relay] Windows fallback pipe reconnect failed, relaunching relay:', - err instanceof Error ? err.message : String(err) - ) - } - } - - const logFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.log') - const errFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.err.log') - // Why no credential write: see launchRelay — the daemon publishes after it owns the pipe. - const launchOutput = await execHostCommand( - conn, - hostPlatform, - windowsRelayLaunchCommand(hostPlatform, { - nodePath: launchOpts.nodePath, - remoteDir: launchOpts.remoteDir, - sockPath: launchOpts.sockPath, - endpointDir: launchOpts.endpointDir, - graceTime: launchOpts.graceTime, - logFile, - errFile, - credentialFile: launchOpts.credentialFile, - ripgrepPath: launchOpts.ripgrepPath - }), - { signal } - ).catch((error: unknown) => { - throw classifyWindowsRelayLaunchError(error) - }) - const launchReport = parseWindowsBreakawayLaunchReport( - RELAY_WINDOWS_BREAKAWAY_CONTRACT, - launchOutput - ) - console.log(`${WINDOWS_RELAY_LAUNCH_LOG_PREFIX}${JSON.stringify(launchReport)}`) - - const POLL_INTERVAL_MS = 200 - const POLL_TIMEOUT_MS = 10_000 - if ( - await waitForWindowsRelayPipe( - conn, - hostPlatform, - launchOpts, - POLL_TIMEOUT_MS, - POLL_INTERVAL_MS, - signal - ) - ) { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) - await rememberWindowsActiveRelayEndpoint( - conn, - hostPlatform, - launchOpts.activePipeMarkerPath, - launchOpts.sockPath, - signal - ) - return { - transport, - nodePath: launchOpts.nodePath, - sockPath: launchOpts.sockPath - } - } - - const logOutput = await execHostCommand( - conn, - hostPlatform, - windowsRelayTailLogCommand(logFile, errFile), - { signal } - ).catch(() => { - signal?.throwIfAborted() - return '(could not read log)' - }) - throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) -} - -async function connectWindowsRelay( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: { - remoteDir: string - nodePath: string - sockPath: string - credentialFile: string - }, - signal?: AbortSignal -): Promise { - const channel = await conn.exec( - windowsRelayConnectCommand( - hostPlatform, - opts.nodePath, - opts.remoteDir, - opts.sockPath, - opts.credentialFile - ), - { wrapCommand: false, signal } - ) - return waitForSentinel(channel, signal) -} - -async function probeWindowsRelayPipe( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: { - remoteDir: string - nodePath: string - sockPath: string - }, - signal?: AbortSignal -): Promise<'READY' | 'WAITING'> { - const result = await execHostCommand( - conn, - hostPlatform, - windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), - { signal } - ) - return result.trim() === 'READY' ? 'READY' : 'WAITING' -} - -async function waitForWindowsRelayPipe( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: { - remoteDir: string - nodePath: string - sockPath: string - }, - timeoutMs: number, - intervalMs: number, - signal?: AbortSignal -): Promise { - try { - const result = await execHostCommand( - conn, - hostPlatform, - windowsRelayWaitCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath, { - timeoutMs, - intervalMs - }), - { signal } - ) - return result.trim() === 'READY' - } catch { - signal?.throwIfAborted() - return false - } -} - -function windowsRelayProbeCommand( - hostPlatform: RemoteHostPlatform, - nodePath: string, - remoteDir: string, - sockPath: string -): string { - const js = [ - 'const net=require("net");', - 'const s=net.connect(process.argv[1]);', - 's.on("connect",()=>{s.destroy();process.stdout.write("READY")});', - 's.on("error",()=>{process.stdout.write("WAITING")});' - ].join('') - return commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `& ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(js)} ${powerShellNativeArg(sockPath)}` - ) -} - -function windowsRelayWaitCommand( - hostPlatform: RemoteHostPlatform, - nodePath: string, - remoteDir: string, - sockPath: string, - opts: { timeoutMs: number; intervalMs: number } -): string { - const js = [ - 'const net=require("net");', - 'const pipe=process.argv[1];', - 'const timeoutMs=Number(process.argv[2]);', - 'const intervalMs=Number(process.argv[3]);', - 'const deadline=Date.now()+timeoutMs;', - 'function finish(value){process.stdout.write(value);process.exit(0)}', - 'function attempt(){', - 'const s=net.connect(pipe);', - 'let settled=false;', - 'function retry(){if(settled)return;settled=true;s.destroy();', - 'if(Date.now()>=deadline)finish("WAITING");else setTimeout(attempt,intervalMs)}', - 's.setTimeout(Math.min(intervalMs,500));', - 's.on("connect",()=>{if(settled)return;settled=true;s.destroy();finish("READY")});', - 's.on("timeout",retry);', - 's.on("error",retry);', - '}', - 'attempt();' - ].join('') - return commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - [ - `& ${powerShellLiteral(nodePath)}`, - '-e', - powerShellNativeArg(js), - powerShellNativeArg(sockPath), - powerShellLiteral(String(opts.timeoutMs)), - powerShellLiteral(String(opts.intervalMs)) - ].join(' ') - ) -} - -function windowsRelayTailLogCommand(logFile: string, errFile: string): string { - const script = [ - `$out = if (Test-Path -LiteralPath ${powerShellLiteral(logFile)}) { Get-Content -LiteralPath ${powerShellLiteral(logFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stdout log)' }`, - `$err = if (Test-Path -LiteralPath ${powerShellLiteral(errFile)}) { Get-Content -LiteralPath ${powerShellLiteral(errFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stderr log)' }`, - 'Write-Output $out', - "Write-Output '--- stderr ---'", - 'Write-Output $err' - ].join('; ') - return powerShellCommand(script) -} diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts deleted file mode 100644 index 9c65ab76fc6f..000000000000 --- a/src/main/ssh/ssh-relay-gc-retry.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+gc-retry') -})) -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn(), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - 'sshChannelCloseConfirmed' in error && - error.sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+gc-retry'), - computeRemoteRelayDir: (home: string, version: string) => `${home}/.orca-remote/relay-${version}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn(), - abandonInstall: vi.fn(), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn(), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (value: string) => `'${value}'`, - createSshOperationAbortError: () => Object.assign(new Error('cancelled'), { name: 'AbortError' }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { - releaseRelayGcClaimWithRetry, - tryAcquireRelayGcClaim, - waitForRelayGcClaimRelease -} from './ssh-relay-gc-claim' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { abandonInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import type { SshConnection } from './ssh-connection' - -function makeConnection(): SshConnection { - const channel = { - on: vi.fn(), - stderr: { on: vi.fn() }, - close: vi.fn() - } - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue(channel), - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection -} - -describe('relay GC deploy retry', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - }) - - it('recomputes install state when GC wins before a healthy relay launch', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('gc') - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await deployAndLaunchRelay(conn) - - expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) - expect(conn.exec).toHaveBeenCalledTimes(2) - }) - - it('recomputes state when GC finishes between the install probe and lock acquisition', async () => { - const conn = makeConnection() - vi.mocked(isRelayAlreadyInstalled) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await deployAndLaunchRelay(conn) - - expect(abandonInstall).toHaveBeenCalledTimes(2) - expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(4) - expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(2) - }) - - it('owns a GC claim through launch when the install-lock state is indeterminate', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('error') - let resolveReady: ((value: Awaited>) => void) | undefined - vi.mocked(waitForSentinel).mockImplementationOnce( - async () => - new Promise>>((resolve) => { - resolveReady = resolve - }) - ) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - const deploy = deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(resolveReady).toBeDefined()) - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - resolveReady?.({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - await deploy - - expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() - expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(1) - expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(1) - expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) - expect(conn.exec).toHaveBeenCalledTimes(2) - }) - - it('retains an owned install lock when launch never becomes live', async () => { - const conn = makeConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') - - expect(abandonInstall).not.toHaveBeenCalled() - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - }) - - it('retains an owned launch claim when launch never becomes live', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('busy') - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') - - expect(abandonInstall).not.toHaveBeenCalled() - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - }) - - it.each(['acquired', 'busy'] as const)( - 'keeps the connected relay but stops setup after uncertain %s fence release', - async (lockResult) => { - const conn = makeConnection() - const error = Object.assign(new Error('release still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) - const release = - lockResult === 'acquired' - ? vi.mocked(abandonInstall) - : vi.mocked(releaseRelayGcClaimWithRetry) - release.mockRejectedValueOnce(error) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - return command.includes('test -S') ? 'DEAD' : '' - }) - - const deployed = await deployAndLaunchRelay(conn) - expect(deployed.transport).toBeDefined() - expect(release).toHaveBeenCalledOnce() - const execCount = vi.mocked(execCommand).mock.calls.length - await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - } - ) - - it.each(['acquired', 'busy'] as const)( - 'preserves the %s fence after an unconfirmed installed-state recheck', - async (lockResult) => { - const conn = makeConnection() - const error = Object.assign(new Error('recheck still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(true).mockRejectedValueOnce(error) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - return 'ORCA-NATIVE-DEPS-OK' - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect(abandonInstall).not.toHaveBeenCalled() - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() - expect(conn.exec).not.toHaveBeenCalled() - } - ) - - it('keeps retrying repeated launch-claim contention within the deploy bound', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('busy') - vi.mocked(tryAcquireRelayGcClaim) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValue('launch-token') - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await deployAndLaunchRelay(conn) - - expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(2) - expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(3) - expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/ssh/ssh-relay-host-node-addons.test.ts b/src/main/ssh/ssh-relay-host-node-addons.test.ts deleted file mode 100644 index 63ea3bc8c0ca..000000000000 --- a/src/main/ssh/ssh-relay-host-node-addons.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' -import type { SshConnection } from './ssh-connection' -import { HOST_NODE_RELAY_RUNTIME_KEY, planHostNodeAddonRelay } from './ssh-relay-host-node-addons' -import { - pinnedNodeRelayFullVersion, - pinnedRelayAddonFiles, - RELAY_RUNTIME_REF_PREFIX -} from './ssh-relay-pinned-node' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const conn = {} as SshConnection -const linux = getRemoteHostPlatform('linux-x64') -const facts = { target: 'linux-x64-glibc' as const, glibc: { major: 2, minor: 31 } } -const dirs: string[] = [] - -function fakeOrcadSlot(): string { - const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) - dirs.push(dir) - for (const file of pinnedRelayAddonFiles('linux-x64-glibc')) { - const path = join(dir, ...file.split('/')) - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, file) - } - return dir -} - -afterEach(() => { - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -const hostNode = { - nodePath: '/usr/bin/node', - facts: { version: { major: 20, minor: 11 }, napi: 9 } -} - -describe('planHostNodeAddonRelay (rung C)', () => { - it('stages the slot addons without a pinned runtime ref, under a version apart from rung A', async () => { - const slot = fakeOrcadSlot() - const resolveHostNode = vi.fn().mockResolvedValue(hostNode) - const plan = await planHostNodeAddonRelay({ - conn, - host: linux, - facts, - baseVersion: '0.1.0+abcdef012345', - materializeOrcad: async () => slot, - resolveHostNode - }) - try { - expect(resolveHostNode).toHaveBeenCalledWith(conn, 8, { signal: undefined }) - expect(plan.nodePath).toBe('/usr/bin/node') - expect(plan.fullVersion).toBe( - pinnedNodeRelayFullVersion( - '0.1.0+abcdef012345', - HOST_NODE_RELAY_RUNTIME_KEY, - plan.addons.digest - ) - ) - expect(plan.fullVersion).not.toBe( - pinnedNodeRelayFullVersion( - '0.1.0+abcdef012345', - NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256, - plan.addons.digest - ) - ) - expect(readdirSync(plan.addons.dir).some((f) => f.startsWith(RELAY_RUNTIME_REF_PREFIX))).toBe( - false - ) - } finally { - await plan.addons.dispose() - } - }) - - it('refuses with host_node_missing when no qualifying host Node answered', async () => { - await expect( - planHostNodeAddonRelay({ - conn, - host: linux, - facts, - baseVersion: '0.1.0+abcdef012345', - resolveHostNode: vi.fn().mockResolvedValue(null) - }) - ).rejects.toMatchObject({ reason: 'host_node_missing' }) - }) - - it('refuses below the addons glibc floor before probing for a Node', async () => { - const resolveHostNode = vi.fn() - await expect( - planHostNodeAddonRelay({ - conn, - host: linux, - facts: { ...facts, glibc: { major: 2, minor: 17 } }, - baseVersion: '0.1.0+abcdef012345', - resolveHostNode - }) - ).rejects.toMatchObject({ reason: 'libc_floor' }) - expect(resolveHostNode).not.toHaveBeenCalled() - }) - - it('lets an unanswered host Node probe propagate instead of stepping down', async () => { - const lost = new Error('channel lost') - await expect( - planHostNodeAddonRelay({ - conn, - host: linux, - facts, - baseVersion: '0.1.0+abcdef012345', - resolveHostNode: vi.fn().mockRejectedValue(lost) - }) - ).rejects.toBe(lost) - }) -}) diff --git a/src/main/ssh/ssh-relay-host-node-addons.ts b/src/main/ssh/ssh-relay-host-node-addons.ts deleted file mode 100644 index 37a6f9032ebf..000000000000 --- a/src/main/ssh/ssh-relay-host-node-addons.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Rung C of the relay runtime ladder (design D6): the relay runs on the host's own Node - * (>= 18) with Orca's prebuilt N-API addons uploaded beside it. No npm, no compiler. - */ -import { ORCAD_ADDON_NAPI_VERSION } from '../../shared/orcad-artifacts' -import type { ServerTarget } from '../../shared/node-runtime-pin' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import type { GlibcVersion, OrcadDeploymentTargetFacts } from './orcad-deployment-target' -import type { SshConnection } from './ssh-connection' -import { - isGlibcBelow, - PINNED_NODE_GLIBC_FLOOR, - PinnedRelayFallbackError, - pinnedNodeRelayFullVersion, - stagePinnedRelayAddons, - type PinnedRelayAddons, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { resolveRemoteHostNodeForAddons } from './ssh-remote-node-addon-resolution' -import type { HostNodeAddonFacts } from './ssh-remote-node-toolchain-probe' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { tmpdir } from 'node:os' - -/** Folded into the relay version in place of a runtime hash, so C never shares A's dir. */ -export const HOST_NODE_RELAY_RUNTIME_KEY = 'host-node' - -/** The server slots' addons are built against this glibc, whichever Node loads them. */ -export const RELAY_ADDON_GLIBC_FLOOR: GlibcVersion = PINNED_NODE_GLIBC_FLOOR - -export type HostNodeAddonRelayPlan = { - kind: 'host-node-addons' - target: ServerTarget - glibc: GlibcVersion | null - fullVersion: string - addons: PinnedRelayAddons - nodePath: string - hostNode: HostNodeAddonFacts -} - -/** A relay launched from uploaded prebuilt addons rather than a host npm install. */ -export type PrebuiltRelayPlan = PinnedRelayPlan | HostNodeAddonRelayPlan - -export async function planHostNodeAddonRelay(options: { - conn: SshConnection - host: RemoteHostPlatform - facts: OrcadDeploymentTargetFacts - baseVersion: string - signal?: AbortSignal - materializeOrcad?: (target: ServerTarget, signal?: AbortSignal) => Promise - resolveHostNode?: typeof resolveRemoteHostNodeForAddons -}): Promise { - const { conn, host, facts, signal } = options - if (host.os === 'win32') { - throw new PinnedRelayFallbackError( - 'windows_host_unsupported', - 'Windows hosts keep the host-Node relay for now' - ) - } - if (facts.glibc && isGlibcBelow(facts.glibc, RELAY_ADDON_GLIBC_FLOOR)) { - throw new PinnedRelayFallbackError( - 'libc_floor', - `host glibc ${facts.glibc.major}.${facts.glibc.minor} is below the prebuilt addons' floor` - ) - } - const resolveHostNode = options.resolveHostNode ?? resolveRemoteHostNodeForAddons - const hostNode = await resolveHostNode(conn, ORCAD_ADDON_NAPI_VERSION, { signal }) - if (!hostNode) { - throw new PinnedRelayFallbackError( - 'host_node_missing', - `no host Node.js 18+ with N-API ${ORCAD_ADDON_NAPI_VERSION} was found` - ) - } - let addons: PinnedRelayAddons - try { - const materialize = - options.materializeOrcad ?? ((t, s) => materializeOrcadArtifact(t, { signal: s })) - addons = await stagePinnedRelayAddons( - await materialize(facts.target, signal), - facts.target, - tmpdir(), - { - runtimeRef: false - } - ) - } catch (error) { - signal?.throwIfAborted() - throw new PinnedRelayFallbackError( - 'artifacts_unavailable', - error instanceof Error ? error.message : String(error) - ) - } - try { - return { - kind: 'host-node-addons', - target: facts.target, - glibc: facts.glibc, - fullVersion: pinnedNodeRelayFullVersion( - options.baseVersion, - HOST_NODE_RELAY_RUNTIME_KEY, - addons.digest - ), - addons, - nodePath: hostNode.nodePath, - hostNode: hostNode.facts - } - } catch (error) { - await addons.dispose() - throw error - } -} diff --git a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts b/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts deleted file mode 100644 index e5949b834114..000000000000 --- a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Design D5/D6 hostile-host matrix: the real client-side relay deploy against container SSH -// targets, and against a macOS runner's own loopback sshd, asserting which rung of the runtime -// ladder each host lands on, and for cells naming one, the runtime managed orcad deploys on. -// -// Run: ORCA_RUN_SSH_HOSTILE_HOSTS=1 pnpm test src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts -// Needs `pnpm build:relay` and an orcad template holding each selected cell's slot. Docker cells -// need Linux Docker (the no-egress cell dials an internal bridge directly) and -// node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl -// macOS cells need /usr/sbin/sshd and this runner's slot (`pnpm build:orcad-prebuilds`, then -// `--targets darwin-arm64` or `darwin-x64`). Only cells this machine can host run. -// ORCA_SSH_HOSTILE_HOST_CELLS=id,id narrows the run. .github/workflows/ssh-hostile-hosts.yml runs it. -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { posix } from 'node:path' -import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' -import { ORCAD_PARCEL_WATCHER_NATIVE } from '../../shared/orcad-artifacts' -import type { SshConnection } from './ssh-connection' -import { HOSTILE_HOST_CELLS, selectHostileHostCells } from './ssh-hostile-host-cells' -import { proveManagedOrcadCell } from './ssh-hostile-host-managed-orcad' -import { - assertCell, - connectHostileHost, - deployOnce, - exerciseLaunchedCell, - installHostileHostAppEnvironment, - TERMINAL_PROBES -} from './ssh-hostile-host-test-harness' -import { - hostExec, - hostExecStatus, - hostileHostObserver, - hostileHostSshTarget, - startHostileHostTarget, - stopHostileHostTarget, - type HostileHostTarget -} from './ssh-hostile-host-test-fixture' - -const RUN = process.env.ORCA_RUN_SSH_HOSTILE_HOSTS === '1' -const SELECTED = new Set( - RUN ? selectHostileHostCells(process.env.ORCA_SSH_HOSTILE_HOST_CELLS).map((cell) => cell.id) : [] -) -const CELL_TIMEOUT_MS = 15 * 60_000 - -/** - * Gatekeeper only evaluates files carrying com.apple.quarantine, which SFTP writes never get; the - * runtime must run as uploaded, with no `xattr -d` (the SSH side's xattr is a forbidden shim). - */ -async function assertRunsWithoutQuarantine( - target: HostileHostTarget, - nodePath: string -): Promise { - const runtimeDir = posix.dirname(posix.dirname(nodePath)) - expect(await hostExec(target, `xattr -r -l '${runtimeDir}'`)).not.toContain( - 'com.apple.quarantine' - ) - expect(await hostExec(target, `'${nodePath}' -p process.version`)).toBe( - `v${NODE_RUNTIME_PIN.version}` - ) -} - -describe('SSH relay hostile-host matrix', () => { - let cleanupAppEnvironment: (() => void) | null = null - - beforeAll(() => { - cleanupAppEnvironment = installHostileHostAppEnvironment() - }) - - afterAll(() => { - cleanupAppEnvironment?.() - }) - - // Why: with every cell skipped the job would pass having deployed nothing. - it.runIf(RUN)('selects at least one cell this machine can host', () => { - expect(SELECTED.size).toBeGreaterThan(0) - }) - - for (const cell of HOSTILE_HOST_CELLS) { - it.skipIf(!SELECTED.has(cell.id))( - `${cell.id} lands on ${cell.expect.outcome === 'launched' ? `rung ${cell.expect.rung}` : cell.expect.outcome}`, - async () => { - let target: HostileHostTarget | null = null - let conn: SshConnection | null = null - try { - target = await startHostileHostTarget(cell) - if (target.kind === 'docker' && target.cell.noEgress) { - expect( - await hostExecStatus(target, "timeout 5 bash -c 'exec 3<>/dev/tcp/1.1.1.1/443'") - ).not.toBe(0) - } - const observer = hostileHostObserver(target) - const sshTarget = hostileHostSshTarget(target) - conn = await connectHostileHost(sshTarget) - const first = await deployOnce(conn) - await assertCell(cell, observer, first) - if (cell.expect.outcome === 'launched') { - const launched = await exerciseLaunchedCell({ - cell, - observer, - sshTarget, - terminal: TERMINAL_PROBES.posix, - first, - firstConn: conn - }) - if (target.kind === 'local-sshd' && target.cell.runsOn.platform === 'darwin') { - await assertRunsWithoutQuarantine(target, launched.nodePath) - } - // The relay runs on without its watcher, so only a direct load proves the slot's build fits. - const watcher = `${first.deployed?.remoteRelayDir}/${ORCAD_PARCEL_WATCHER_NATIVE}` - await hostExec(target, `'${launched.nodePath}' -e "require('${watcher}')"`) - } else if ( - cell.expect.outcome !== 'legacy_opt_out' && - cell.expect.refusals[0]?.reason === 'libc_floor' - ) { - // Refused from the probe alone: nothing was uploaded. - expect( - await hostExecStatus(target, 'ls -d /root/.orca-remote/runtimes/node-*') - ).not.toBe(0) - } - } finally { - await conn?.disconnect().catch(() => {}) - await stopHostileHostTarget(target) - } - }, - CELL_TIMEOUT_MS - ) - } - - // A fresh host per cell: the relay pass above leaves a relay and its runtime behind. - for (const cell of HOSTILE_HOST_CELLS.filter((candidate) => candidate.managed)) { - it.skipIf(!SELECTED.has(cell.id))( - `${cell.id} deploys managed orcad on ${cell.managed?.runtime} (${cell.managed?.outcome})`, - async () => { - let target: HostileHostTarget | null = null - try { - target = await startHostileHostTarget(cell) - await proveManagedOrcadCell(cell, target, hostileHostSshTarget(target)) - } finally { - await stopHostileHostTarget(target) - } - }, - CELL_TIMEOUT_MS - ) - } -}) diff --git a/src/main/ssh/ssh-relay-live-connect.test.ts b/src/main/ssh/ssh-relay-live-connect.test.ts deleted file mode 100644 index 8ebf3f7f9f49..000000000000 --- a/src/main/ssh/ssh-relay-live-connect.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterAll, describe, expect, it, vi } from 'vitest' - -// Live end-to-end harness for ssh:connect against a real host. Skipped unless -// ORCA_LIVE_SSH_HOST is set; never runs in normal CI or unit-test loops. -vi.mock('electron', () => ({ - app: { getAppPath: () => process.cwd() } -})) - -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshConnection } from './ssh-connection' -import { resolveSshConfigHomePath } from './ssh-config-path-expansion' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import type { SshTarget } from '../../shared/ssh-types' - -const LIVE_HOST = process.env.ORCA_LIVE_SSH_HOST -const LIVE_USER = process.env.ORCA_LIVE_SSH_USER ?? process.env.USERNAME ?? process.env.USER ?? '' -const LIVE_IDENTITY = resolveSshConfigHomePath( - process.env.ORCA_LIVE_SSH_IDENTITY ?? '~/.ssh/id_ed25519' -) -const rawLivePort = process.env.ORCA_LIVE_SSH_PORT -const LIVE_PORT = rawLivePort ? Number.parseInt(rawLivePort, 10) : 22 - -const startedAt = Date.now() -function log(step: string): void { - const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1) - console.log(`[live-connect +${elapsed}s] ${step}`) -} - -describe.skipIf(!LIVE_HOST)('live ssh:connect pipeline', () => { - const cleanups: (() => Promise | void)[] = [] - - afterAll(async () => { - for (const cleanup of cleanups.toReversed()) { - try { - await cleanup() - } catch (err) { - // Best-effort teardown; the relay grace period reaps leftovers. - log(`cleanup error: ${err instanceof Error ? err.message : String(err)}`) - } - } - }) - - it('connects, deploys the relay, and spawns a real PTY', { timeout: 360_000 }, async () => { - if (!Number.isInteger(LIVE_PORT) || LIVE_PORT < 1 || LIVE_PORT > 65535) { - throw new Error(`Invalid ORCA_LIVE_SSH_PORT: ${rawLivePort}`) - } - - const target: SshTarget = { - id: 'live-connect-harness', - label: 'live-connect-harness', - host: LIVE_HOST!, - port: LIVE_PORT, - username: LIVE_USER, - identityFile: LIVE_IDENTITY, - source: 'manual' - } - - log(`connecting to ${LIVE_USER}@${LIVE_HOST}:${LIVE_PORT}`) - const conn = new SshConnection(target, { - onStateChange: (_id, state) => { - log(`state=${state.status}${state.error ? ` error=${state.error}` : ''}`) - } - }) - cleanups.push(() => conn.disconnect()) - await conn.connect() - log('ssh connection established') - - const deployed = await deployAndLaunchRelay( - conn, - (status) => log(`deploy: ${status}`), - 30, - 'live-connect-harness' - ) - log(`relay launched (remoteRelayDir=${deployed.remoteRelayDir})`) - - const mux = new SshChannelMultiplexer(deployed.transport) - cleanups.push(() => mux.dispose()) - - const home = await mux.request('session.resolveHome', { path: '~' }) - log(`session.resolveHome -> ${JSON.stringify(home)}`) - expect(home).toBeTruthy() - - const spawned = (await mux.request('pty.spawn', { - cols: 80, - rows: 24 - })) as { id: string } - log(`pty.spawn -> id=${spawned.id}`) - expect(spawned.id).toBeTruthy() - - await mux.request('pty.shutdown', { id: spawned.id }) - log('pty.shutdown ok: full connect pipeline verified') - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts deleted file mode 100644 index 10b1ed620f34..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -// The claim this file has to hold up: a second deploy of a *different bundle* to the same host -// runs no `npm install` at all. Everything else here is the fallback ladder underneath it — a -// host that cannot link, cannot publish, or answers nothing still deploys exactly as before. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' -import { - makeMockConnection, - makeStagedFirstInstallExecPrefix, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' -import { - RELAY_NATIVE_CACHE_LINKED, - RELAY_NATIVE_CACHE_MISS, - RELAY_NATIVE_CACHE_PROMOTED -} from './ssh-relay-native-deps-cache-commands' - -// Everything after the probe on a healthy install: stderr cleanup, stage cleanup, launch. -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const LAUNCH_TAIL: ExecResponse[] = ['', 'DEAD', '', 'READY'] - -describe('relay native-deps cache on the deploy path', () => { - let warnSpy: ReturnType - const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const k of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[k] - } - for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[k] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - }) - - function feed(responses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const r of responses) { - if (typeof r === 'string') { - mockExec.mockResolvedValueOnce(r) - } else { - mockExec.mockRejectedValueOnce(new Error(r.reject)) - } - } - } - - function execCommands(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => command) - } - - /** - * A first install whose cache probe answers `cacheAnswer`. The prefix's last slot is the cache - * probe, so overriding it is the only difference between a hit and a miss. - */ - function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { - const prefix = makeStagedFirstInstallExecPrefix() - prefix[prefix.length - 1] = cacheAnswer - return [...prefix, ...tail] - } - - it('runs no npm install when a different bundle finds a complete entry on the host', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds, through the symlink - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const commands = execCommands() - expect(commands.some((c) => c.includes('npm install'))).toBe(false) - expect(commands.some((c) => c.includes('npm rebuild'))).toBe(false) - // The bundle still gets its own directory; only the native tree is shared. - expect(commands.some((c) => c.includes('.orca-remote/relay-0.1.0+testhash'))).toBe(true) - expect(commands.some((c) => /\.orca-remote\/native\/linux-x64-[0-9a-f]{16}/.test(c))).toBe(true) - }) - - it('still installs on the first deploy, then publishes the tree the probe loaded', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_MISS, [ - '', // npm install - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - RELAY_NATIVE_CACHE_PROMOTED, - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const commands = execCommands() - const install = commands.find((c) => c.includes('npm install')) ?? '' - expect(install).toContain('node-pty@1.1.0') - // The install runs in the relay directory; publication moves the finished tree afterwards. - expect(install).toContain('.orca-remote/relay-0.1.0+testhash') - const promote = commands.findLast((c) => c.includes('mkdir "$cache"')) ?? '' - expect(promote).toContain(': > "$cache/.deps-complete"') - }) - - it('does not publish a tree the probe could not load', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_MISS, [ - '', // npm install - '', // chmod prebuilds - 'MISSING\n', - '', // cat probe stderr - '', // rm probe stderr - '', // npm rebuild - '', // chmod prebuilds after rebuild - 'MISSING\n', - '', // cat stderr after rebuild - '', // rm stderr after rebuild - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - expect(execCommands().some((c) => c.includes('mkdir "$cache"'))).toBe(false) - }) - - it('installs per-directory when the host cannot answer the cache probe at all', async () => { - const conn = makeMockConnection(sftpCapture) - const prefix = makeStagedFirstInstallExecPrefix() - prefix[prefix.length - 1] = { reject: 'mkdir: Read-only file system' } - feed([ - ...prefix, - '', // npm install still runs - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // publication is attempted and answers nothing - ...LAUNCH_TAIL - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - expect(execCommands().some((c) => c.includes('npm install'))).toBe(true) - }) - - it('falls back to its own install when a linked entry does not load on this host', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds - 'MISSING\n', // the shared tree does not load here - '', // cat probe stderr - '', // rm probe stderr - '', // npm install, privately, after the prefix detaches the symlink - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // promotion attempt (the entry already exists, so it is declined) - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((c) => c.includes('npm install')) ?? '' - // Why this prefix is the whole point: npm follows the symlink, and every other relay on the - // host is running out of the tree on the other side of it. - expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') - const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnings.some((m) => m.includes('[ssh-relay][NATIVE-CACHE-UNUSABLE]'))).toBe(true) - }) - - it('detaches rather than resetting through the link when repairing an installed relay', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - const bothMissing = 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - bothMissing, // health probe before the repair lock - bothMissing, // re-probe under the lock - '', // install-owner marker - '', // npm install - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const commands = execCommands() - // A repair never consults the shared entry: its reset would rewrite a tree it does not own. - expect(commands.some((c) => c.includes('.orca-remote/native/'))).toBe(false) - const install = commands.find((c) => c.includes('npm install')) ?? '' - expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') - expect(install).toContain("rm -rf 'node_modules/node-pty'") - }) - - it('pins its own key so a GC pass cannot collect the entry this connection depends on', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - await vi.waitFor(() => expect(vi.mocked(gcOldRelayVersions)).toHaveBeenCalled()) - - const options = vi.mocked(gcOldRelayVersions).mock.calls.at(-1)?.[4] - expect(options?.nativeDepsCacheKeys).toEqual([ - expect.stringMatching(/^linux-x64-[0-9a-f]{16}$/) - ]) - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-install.ts b/src/main/ssh/ssh-relay-native-deps-cache-install.ts deleted file mode 100644 index 30ff68862982..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-install.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * The deploy-side half of the shared native-deps cache: resolve this build's key, try to link an - * existing entry, and publish a probe-verified tree afterwards. - * - * Both entry points answer with a value, never an exception. The cache is an optimization on a - * path that must still connect a host with a read-only home, no `ln`, or an SSH server that drops - * the channel — every one of those is a plain per-directory install, which is what the relay did - * before this existed. - */ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import type { SshConnection } from './ssh-connection' -import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' -import { execHostCommand } from './ssh-relay-host-exec' -import { NATIVE_DEPS_COMMAND_TIMEOUT_MS } from './ssh-relay-deploy-timing' -import { - computeRelayNativeDepsCacheKey, - supportsRelayNativeDepsCache, - RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN, - type RelayNativeDepsCachePatchSource -} from './ssh-relay-native-deps-cache' -import { - ensureRelayNativeDepsCacheCommand, - promoteRelayNativeDepsCacheCommand, - RELAY_NATIVE_CACHE_LINKED, - RELAY_NATIVE_CACHE_PROMOTED, - RELAY_NATIVE_CACHE_SEEDED, - type RelayNativeDepsCachePaths -} from './ssh-relay-native-deps-cache-commands' -import type { RemoteHostPlatform } from './ssh-remote-platform' - -/** - * `linked` — the relay directory now points at a complete shared entry and needs no install. - * `private` — it owns (or is about to own) its own tree, which promotion may later publish. - */ -export type RelayNativeDepsCacheAttachment = { - mode: 'linked' | 'private' - key: string -} - -export type RelayNativeDepsCacheContext = { - hostPlatform: RemoteHostPlatform - remoteHome: string - relayDir: string - platform: string - localRelayDir: string - deps: Readonly> - signal?: AbortSignal -} - -/** - * Every shipped artifact that patches the installed native tree, read for hashing. - * - * Reading is best-effort by design: a patch this client cannot read must not silently drop out of - * the key, so an unreadable one disables the cache rather than producing a key that claims the - * patch was applied. - */ -export function readRelayNativeDepsPatchSources( - localRelayDir: string -): RelayNativeDepsCachePatchSource[] | null { - const sources: RelayNativeDepsCachePatchSource[] = [] - for (const artifact of RELAY_ARTIFACTS) { - if (!RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(artifact.filename)) { - continue - } - const path = join(localRelayDir, artifact.filename) - try { - if (!existsSync(path)) { - continue - } - sources.push({ filename: artifact.filename, contents: readFileSync(path, 'utf-8') }) - } catch { - return null - } - } - return sources -} - -/** This build's cache key, or null when it cannot be computed and the cache must stay off. */ -export function resolveRelayNativeDepsCacheKey(context: { - platform: string - localRelayDir: string - deps: Readonly> -}): string | null { - const patchSources = readRelayNativeDepsPatchSources(context.localRelayDir) - if (!patchSources) { - return null - } - try { - return computeRelayNativeDepsCacheKey({ - platform: context.platform, - deps: context.deps, - patchSources - }) - } catch (err) { - console.warn( - `[ssh-relay] Native-deps cache key unavailable for ${context.platform}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return null - } -} - -/** - * Link a complete entry, or leave the relay directory to install privately. - * - * Returns null when the cache is off for this host, which keeps the caller on the exact command - * sequence it ran before the cache existed. - */ -export async function attachRelayNativeDepsCache( - conn: SshConnection, - context: RelayNativeDepsCacheContext -): Promise { - if (!supportsRelayNativeDepsCache(context.hostPlatform)) { - return null - } - const key = resolveRelayNativeDepsCacheKey(context) - if (!key) { - return null - } - const paths = cachePathsFor(context, key) - try { - // Why the native-deps budget and not the default 30s: a seeding copy moves a whole - // node_modules on the host's own disk, which is fast but not instant on a cold cache. - const output = await execHostCommand( - conn, - context.hostPlatform, - ensureRelayNativeDepsCacheCommand(paths, context.deps), - { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal: context.signal } - ) - if (output.includes(RELAY_NATIVE_CACHE_LINKED)) { - console.log(`[ssh-relay] Native deps linked from shared cache entry ${key}`) - return { mode: 'linked', key } - } - if (output.includes(RELAY_NATIVE_CACHE_SEEDED)) { - console.log(`[ssh-relay] Seeded native deps for ${key} from an existing install on this host`) - } - return { mode: 'private', key } - } catch (err) { - context.signal?.throwIfAborted() - // Why still 'private' and not null: the relay directory owns nothing yet either way, and the - // install that follows is identical. Promotion afterwards is separately best-effort. - console.warn( - `[ssh-relay] Native-deps cache probe for ${key} failed; installing per-directory: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return { mode: 'private', key } - } -} - -/** - * Publish a private tree the probe just loaded, and link the relay directory to it. - * - * Called only after `probeInstalledNativeDeps` reported both addons loadable on this host, so an - * entry is never published on the strength of a successful `npm install` alone. - */ -export async function promoteRelayNativeDepsCache( - conn: SshConnection, - context: RelayNativeDepsCacheContext, - key: string -): Promise { - try { - const output = await execHostCommand( - conn, - context.hostPlatform, - promoteRelayNativeDepsCacheCommand(cachePathsFor(context, key)), - { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal: context.signal } - ) - console.log( - output.includes(RELAY_NATIVE_CACHE_PROMOTED) - ? `[ssh-relay] Published native deps as shared cache entry ${key}` - : `[ssh-relay] Native deps stay per-directory; shared cache entry ${key} was not published` - ) - } catch (err) { - context.signal?.throwIfAborted() - console.warn( - `[ssh-relay] Could not publish native-deps cache entry ${key}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } -} - -function cachePathsFor( - context: RelayNativeDepsCacheContext, - key: string -): RelayNativeDepsCachePaths { - return { - host: context.hostPlatform, - remoteHome: context.remoteHome, - relayDir: context.relayDir, - key - } -} diff --git a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts deleted file mode 100644 index 3a21573fbd33..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { - abandonInstall, - finalizeInstall, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { - makeExecResponses, - makeStagedFirstInstallExecPrefix, - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' - -describe('installNativeDeps staged uploads', () => { - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const key of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[key] - } - for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[key] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of execResponses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - it('writes a hardcoded package.json BEFORE running npm install', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const pkgPath = sftpCapture.paths.find((path) => path.endsWith('/package.json')) - expect(pkgPath, 'package.json must be written via SFTP').toBeTruthy() - - const written = sftpCapture.contents[pkgPath as string] - expect(written).toBeTruthy() - const parsed = JSON.parse(written) as Record - expect(parsed.name).toBe('orca-relay') - expect(parsed.version).toBe('1.0.0') - expect(parsed.private).toBe(true) - expect(parsed.type).toBe('commonjs') - expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) - expect(parsed.allowScripts).toEqual({ - '@parcel/watcher@2.5.6': true, - 'node-pty@1.1.0': true - }) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const npmInstallIdx = execCalls.findIndex( - (command) => - command.includes('npm install') && - command.includes('node-pty') && - command.includes('@parcel/watcher') - ) - expect(npmInstallIdx).toBeGreaterThanOrEqual(0) - expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') - const writeObservedAt = sftpCapture.execCallCountAtWrite[pkgPath as string] - expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx) - }) - - it('exports the host Node headers dir to node-gyp on every command that can compile node-pty (STA-6674)', async () => { - const conn = makeMockConnection(sftpCapture) - // Install succeeds, the probe fails, the rebuild repairs it, then the cloexec patch rebuilds again. - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const compiling = ['npm install', 'npm rebuild', 'node-pty-1.1.0-master-cloexec-patch.cjs'] - for (const compileStep of compiling) { - const command = commands.find((candidate) => candidate.includes(compileStep)) - expect(command, compileStep).toBeDefined() - // Both spellings: node-gyp 10 (Node 20) reads only npm_config_, node-gyp >= 11.4 prefers the other. - expect(command).toContain('export npm_config_nodedir=') - expect(command).toContain('npm_package_config_node_gyp_nodedir=') - // The export precedes the compile on the same command line, and only when the probe found headers. - expect(command!.indexOf('npm_config_nodedir')).toBeLessThan(command!.indexOf(compileStep)) - expect(command).toContain('node_version.h') - // The marker lands in the captured output, so a failure after it can say what was exported. - expect(command).toContain('echo "ORCA-NODE-HEADERS:${ORCA_NODE_HEADERS_DIR:-none}"') - } - }) - - // What execCommand actually rejects with: the whole command line (marker echo included) quoted - // ahead of the host's output. A fixture that omits the command hides the marker-parsing bug. - function rejectNpmInstallLikeExecCommand(hostOutput: string): void { - vi.mocked(execCommand).mockImplementationOnce(async (_conn, command) => { - throw new Error(`Command "${command}" failed (exit 1): ${hostOutput}`) - }) - } - const HEADERS_REFUSED = - 'npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\nnpm error gyp ERR! configure error' - - it('names the fix when node-gyp cannot download headers and the host ships none (STA-6674)', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - rejectNpmInstallLikeExecCommand(`ORCA-NODE-HEADERS:none\n${HEADERS_REFUSED}`) - feed(['']) // clean stage root - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('could not download the Node.js headers') - expect((error as Error).message).toContain('no local headers matching its own version') - expect((error as Error).message).not.toContain('Orca defect') - expect((error as Error).message).toContain('ECONNREFUSED') - // A full toolchain: the toolchain probe must not run, and this is not a "build tools" error. - expect((error as Error).message).not.toContain('build tools') - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('command -v "$t"'))).toBe(false) - }) - - it('reports an Orca defect when headers were exported but node-gyp downloaded anyway', async () => { - // The marker says the export happened; a download after it means node-gyp never read the env. - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - rejectNpmInstallLikeExecCommand(`ORCA-NODE-HEADERS:/usr/local\n${HEADERS_REFUSED}`) - feed(['']) // clean stage root - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('/usr/local/include/node') - expect((error as Error).message).toContain('Orca defect') - expect((error as Error).message).not.toContain('no local headers matching its own version') - }) - - it('promotes only after the first-install lock is acquired', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - vi.mocked(acquireInstallLock).mockImplementationOnce(async () => { - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('cp -a'))).toBe(false) - }) - - await deployAndLaunchRelay(conn) - - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const promotionIndex = commands.findIndex((command) => command.includes('cp -a')) - const npmIndex = commands.findIndex((command) => command.includes('npm install')) - expect(promotionIndex).toBeGreaterThanOrEqual(0) - expect(npmIndex).toBeGreaterThan(promotionIndex) - }) - - it('cleans a staged upload when cancellation wins before lock acquisition', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - vi.mocked(acquireInstallLock).mockImplementationOnce( - (_conn, _remoteDir, _host, options) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { - once: true - }) - }) - ) - vi.mocked(execCommand).mockResolvedValueOnce('') - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - const result = await deploy - - expect(result).toBeInstanceOf(Error) - expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1) - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('cp -a'))).toBe(false) - expect(commands.some((command) => command.includes('rm -rf'))).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('retains the lock after an unconfirmed promotion termination', async () => { - const conn = makeMockConnection(sftpCapture) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/u') - .mockResolvedValueOnce('') - .mockResolvedValueOnce( - '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0' - ) - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockRejectedValueOnce( - Object.assign(new Error('promotion termination was not confirmed'), { - sshChannelCloseConfirmed: false - }) - ) - .mockResolvedValueOnce('') - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow( - 'promotion termination was not confirmed' - ) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts deleted file mode 100644 index f6d16eaed8e4..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ /dev/null @@ -1,963 +0,0 @@ -// Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. - -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -// Why: the post-launch ripgrep install would consume this file's queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { - abandonInstall, - finalizeInstall, - gcOldRelayVersions, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { - BOTH_NATIVE_DEPS_MISSING_PROBE, - decodePowerShellCommand, - makeExecResponses, - makeMockConnection, - makeRepairToolchainSkipExecResponses, - makeStagedFirstInstallExecPrefix, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' - -describe('installNativeDeps (via deployAndLaunchRelay)', () => { - let warnSpy: ReturnType - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - // mockReset because clearAllMocks keeps queued mockResolvedValueOnce entries, so a leaked response would bleed into the next test. - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const k of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[k] - } - for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[k] - } - // Re-prime as defense-in-depth: factory mocks survive clearAllMocks but a test's own resetAllMocks would drop them. - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const r of execResponses) { - if (typeof r === 'string') { - mockExec.mockResolvedValueOnce(r) - } else { - mockExec.mockRejectedValueOnce(new Error(r.reject)) - } - } - } - - it('propagates a hard `npm install` failure so the deploy aborts before finalizeInstall', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'npm ERR! E404 Not Found node-pty' }, - probe: 'ok' - }) - ) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/npm ERR/) - - // Regression: previously the catch swallowed the throw and finalizeInstall ran anyway. - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe(true) - }) - - it('connects without node-pty when the remote toolchain is missing, instead of failing the host', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - nodePtySkipRetry: 'ok', - toolchainProbe: 'PKG apk' - }) - ) - - // node-pty only backs terminals, so a host that cannot compile it still gets files and git. - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - expect(vi.mocked(finalizeInstall)).toHaveBeenCalled() - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const reinstall = execCalls.findLast((c) => c.includes('npm install')) ?? '' - expect(reinstall).toContain('@parcel/watcher@') - expect(reinstall).not.toContain('node-pty@') - // The skip path must stop here: rebuilding is pointless on a host with no compiler. - expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) - // node-pty is legitimately absent, so its probe result must not raise the degraded-mode alarm. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED]'))).toBe( - false - ) - // The rewritten manifest must drop node-pty too, or npm reconciles it back and rebuilds. - const pkgPath = sftpCapture.paths.findLast((p) => p.endsWith('/package.json')) as string - // The capture concatenates every write to a path; the rewrite is the last manifest line. - const latest = sftpCapture.contents[pkgPath].trim().split('\n').at(-1) as string - const deps = (JSON.parse(latest) as { dependencies: object }).dependencies - expect(deps).toHaveProperty('@parcel/watcher') - expect(deps).not.toHaveProperty('node-pty') - }) - - it('warns when @parcel/watcher is also unloadable after node-pty was skipped', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - nodePtySkipRetry: 'ok', - nodePtySkipWatcher: 'missing' - }) - ) - - // Watcher failure (e.g. glibc below the floor) is non-fatal, but silent dead file watching is not acceptable. - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED]'))).toBe( - true - ) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) - }) - - it('hard-fails on a gyp error when the remote toolchain is actually complete', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - // Probe contradicts the gyp output: the tools are all there, so the real cause is unknown. - toolchainProbe: 'HAVE make\nHAVE g++\nHAVE python3\nPKG apt-get' - }) - ) - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - // Degrading here would silently drop terminals on a host that can build them. - expect((error as Error).message).toContain('gyp ERR!') - expect((error as Error).message).not.toContain('build tools') - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.filter((c) => c.includes('npm install'))).toHaveLength(1) - expect(execCalls.some((c) => c.includes("rm -rf 'node_modules/node-pty'"))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('still reports the actionable build-tools error when the node-pty-less retry also fails', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - // No HAVE lines + apk present: the tailored hint must come from the remote probe, not a hardcoded apt fallback. - toolchainProbe: 'PKG apk', - nodePtySkipRetry: { reject: 'npm ERR! registry unreachable' } - }) - ) - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect(error).toBeInstanceOf(Error) - const message = (error as Error).message - // Actionable: names the missing tools and the exact install command. - expect(message).toContain('build tools') - expect(message).toContain('make') - expect(message).toContain('sudo apk add build-base python3') - // The raw npm/node-gyp output is preserved for triage, not discarded. - expect(message).toContain('not found: make') - // The retry's own cause is unrelated to the toolchain, so it must survive as cause + a log line. - expect((error as Error).cause).toBeInstanceOf(Error) - expect(((error as Error).cause as Error).message).toContain('registry unreachable') - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect( - warnMessages.some( - (m) => m.includes('[ssh-relay][NPTY-SKIP-RETRY-FAIL]') && m.includes('registry unreachable') - ) - ).toBe(true) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('preserves the original npm error when it is not a native build-tool failure', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'npm ERR! network ETIMEDOUT' }, - probe: 'ok', - // A network error must stay a network error even if the host also lacks build tools. - toolchainProbe: 'PKG apt-get' - }) - ) - - // Non-toolchain npm error (network/registry): surface the real error, not a misleading "install build tools". - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('npm ERR! network ETIMEDOUT') - expect((error as Error).message).not.toContain('build tools') - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('command -v "$t"'))).toBe(false) - }) - - it('preserves redirected npm stdout for non-toolchain failures without probing', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { - reject: - 'Command "export PATH=/usr/bin:$PATH && cd /home/u/.orca-remote/relay && npm install node-pty@1.1.0 2>&1" failed (exit 1): npm ERR! network ETIMEDOUT' - }, - probe: 'ok', - toolchainProbe: 'PKG apt-get' - }) - ) - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('npm ERR! network ETIMEDOUT') - expect((error as Error).message).not.toContain('build tools') - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('command -v "$t"'))).toBe(false) - }) - - it('warns clearly when node-pty installs but require() fails (built-but-unloadable)', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' })) - - await deployAndLaunchRelay(conn) - - // Probe failure is non-fatal by design (docs/ssh-relay-versioned-install-dirs.md): throwing would loop reconnects forever where node-pty can't build. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) - - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('rebuilds unloadable native deps and recovers before first relay launch', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const failedProbeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) - const rebuildIdx = execCalls.findIndex((c) => c.includes('npm rebuild')) - const repairedProbeIdx = execCalls.findIndex( - (c, index) => index > rebuildIdx && c.includes('require("node-pty")') - ) - expect(rebuildIdx).toBeGreaterThan(failedProbeIdx) - expect(execCalls[rebuildIdx]).toContain('--ignore-scripts=false') - expect(repairedProbeIdx).toBeGreaterThan(rebuildIdx) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('propagates an SSH-channel failure from the post-rebuild re-probe', async () => { - // Why: rebuild degrades gracefully, but the post-rebuild probe must surface transport death, else a dead channel finalizes a half-repaired install. - const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install native deps - '', // chmod prebuilds - 'MISSING\n', // first probe: require() fails - '', // cat probe stderr - '', // rm probe stderr - '', // npm rebuild native deps - '', // chmod prebuilds after rebuild - { reject: 'SSH channel closed during native deps re-probe' } // re-probe rejects - ]) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel closed/) - - // Rebuild failure is swallowed; a re-probe transport failure must not be. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('aborts an in-progress native install and releases its lock at deploy timeout', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - let installSignal: AbortSignal | undefined - vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { - expect(command).toContain('npm install') - installSignal = options?.signal - return new Promise((_resolve, reject) => { - installSignal?.addEventListener('abort', () => reject(installSignal?.reason), { - once: true - }) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(installSignal).toBeDefined()) - - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe( - `Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s` - ) - expect(installSignal?.aborted).toBe(true) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('does not rewrite node_modules when the health probe never answered', async () => { - // Why (#14830): a wedged `require("node-pty")` makes the probe time out. Reading that silence - // as "every native dep is missing" sent a healthy install through npm install + rebuild that - // could not help, and the retry loop burned the whole deploy budget at "Deploying relay…". - const conn = makeMockConnection(sftpCapture) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - { reject: 'Command "node -e ..." timed out after 30s' }, // health probe never answered - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - const outcome = await deployAndLaunchRelay(conn).catch((error: Error) => error) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) - expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('Repairing missing native deps'))).toBe(false) - // Probe diagnostic wording changes independently of the repair contract. - expect(execCalls.some((c) => c.includes("rm -rf 'node_modules/node-pty'"))).toBe(false) - expect(execCalls.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) - const healthProbeIndex = execCalls.findIndex((command) => - command.includes('ORCA-NATIVE-DEPS-OK') - ) - await expect(vi.mocked(execCommand).mock.results[healthProbeIndex].value).rejects.toThrow( - 'Command "node -e ..." timed out after 30s' - ) - expect( - outcome, - 'an unanswered health probe must still allow an intact relay to launch' - ).not.toBeInstanceOf(Error) - }) - - it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: { reject: 'SSH channel closed unexpectedly' } - }) - ) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel/) - - // Pin the rejection to the PROBE call so slot-ordering drift can't pass this test via a different failure path. - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const probeCallIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) - const npmInstallIdx = execCalls.findIndex( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - expect(probeCallIdx, 'probe must have been invoked').toBeGreaterThanOrEqual(0) - // Probe must come strictly after npm install, else it probes an empty dir and doesn't represent the real-world race. - expect(probeCallIdx).toBeGreaterThan(npmInstallIdx) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - // Channel failure must not be conflated with "node-pty missing" or "npm install failed". - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe( - false - ) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - // Lock must be released so a future reconnect can retry. - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('throws (rather than warns MISSING) when the install dir vanishes between npm install and probe', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'dir-gone' })) - - // Why: cd-failure short-circuits the probe's && so it rejects instead of resolving MISSING; conflating "dir gone" with "node-pty missing" strands the user in degraded mode. - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/cd:/) - - // Pin the rejection to the probe slot so a refactor moving probe before npm install can't pass this test for the wrong reason. - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const probeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) - const npmInstallIdx = execCalls.findIndex( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - expect(probeIdx).toBeGreaterThan(npmInstallIdx) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('uses `node -e require()` rather than `test -d` so unloadable installs are caught', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const probeCmds = vi - .mocked(execCommand) - .mock.calls.map(([, c]) => c) - .filter((c) => c.includes(`require("node-pty")`)) - - // Why: probe must require('node-pty') via the node binary; a weaker test -d passes even when the native binding load is broken. - expect(probeCmds.length).toBeGreaterThan(0) - expect(probeCmds[0]).toMatch(/node['"]?\s+-e/) - - // Pin order npm install → chmod prebuilds → probe: chmod-after-probe breaks spawn-helper bits; probe-before-install tests an empty dir. - const all = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const npmIdx = all.findIndex( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - const chmodPrebuildsIdx = all.findIndex( - (c) => c.includes('spawn-helper') && c.includes('chmod +x') - ) - const probeIdx = all.findIndex((c) => c.includes('require("node-pty")')) - expect(npmIdx).toBeGreaterThanOrEqual(0) - expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx) - expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx) - - // Hold the install lock through launch, then release it exactly once. - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { - const conn = makeMockConnection(sftpCapture) - // Why: noisy .bashrc/MOTD can prefix probe stdout; production uses .includes(PROBE_OK) so pre-sentinel noise still resolves OK. - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: 'ok', - probeStdoutOverride: 'Welcome to Acme Corp\nLast login: ...\nORCA-NPTY-PROBE-OK\n' - }) - ) - - await deployAndLaunchRelay(conn) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('detects MISSING even when the shell prepends noise before the MISSING token', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: 'missing', - probeStdoutOverride: '(node:1234) [DEP0040] DeprecationWarning: ...\nMISSING\n' - }) - ) - - await deployAndLaunchRelay(conn) - - // Absence of PROBE_OK triggers the warn regardless of surrounding noise; finalize still runs by design. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => { - vi.mocked(parseUnameToRelayPlatform).mockReturnValueOnce('win32-x64') - vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Windows AMD64', - 'C:\\Users\\u', - '', // bounded stale-stage recovery - '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0', - '__ORCA_UPLOAD_STAGE_PROMOTION__.sftp-namespace-00000000000000000000000000000000:PROMOTED', - '', // npm install native deps - 'MISSING\n', // native process exit normalized by PowerShell command - '', // npm rebuild native deps - 'MISSING\n', // rebuilt native process still cannot load - '', // clean stage root - '', // no persisted active pipe marker - 'WAITING', // initial pipe probe - '', // WMI relay launch - 'READY', // readiness poll - '' // persist active pipe marker - ]) - - await deployAndLaunchRelay(conn) - - const probeCommand = - vi - .mocked(execCommand) - .mock.calls.map(([, c]) => c) - .find((command) => decodePowerShellCommand(command)?.includes('require(\\"node-pty\\")')) ?? - '' - const probeScript = decodePowerShellCommand(probeCommand) ?? '' - expect(probeScript).toContain('$LASTEXITCODE -ne 0') - expect(probeScript).toContain("'MISSING'") - expect(probeScript).toContain('loadNativeModule') - - const npmScripts = vi - .mocked(execCommand) - .mock.calls.map(([, command]) => decodePowerShellCommand(command) ?? '') - .filter((script) => script.includes('npm install') || script.includes('npm rebuild')) - expect(npmScripts).toHaveLength(2) - expect(npmScripts.every((script) => script.includes('--ignore-scripts=false'))).toBe(true) - expect( - npmScripts.every((script) => - script.includes('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }') - ) - ).toBe(true) - expect( - vi - .mocked(execCommand) - .mock.calls.some(([, command]) => command.includes('.npty-probe.stderr')) - ).toBe(false) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { - // Platform tuple lets bug reports be triaged for prebuild availability without asking the user for their arch. - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' })) - await deployAndLaunchRelay(conn) - const missingMsgs = warnSpy.mock.calls - .map((args) => String(args[0] ?? '')) - .filter((m) => m.includes('[ssh-relay][NPTY-MISSING]')) - expect(missingMsgs.length).toBeGreaterThan(0) - expect(missingMsgs[0]).toContain('linux-x64') - }) - - it('writes an idempotent package.json (same bytes on every install)', async () => { - // First install run. - const conn1 = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - await deployAndLaunchRelay(conn1) - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalled()) - const firstPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string - const first = sftpCapture.contents[firstPath] - - // Reset capture, run again as if it were a fresh install of the same dir. - sftpCapture.paths.length = 0 - for (const k of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[k] - } - for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[k] - } - vi.mocked(execCommand).mockReset().mockResolvedValue('') - - const conn2 = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - await deployAndLaunchRelay(conn2) - const secondPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string - const second = sftpCapture.contents[secondPath] - - expect(second).toBe(first) - }) - - it('repairs an existing complete relay dir that is missing @parcel/watcher', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock - 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - 'ORCA-NPTY-CLOEXEC:patched\n', // pty-master cloexec patch on the loadable node-pty - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - expect(vi.mocked(tryAcquireRelayRepairLock).mock.calls[0]?.[3]?.signal).toBeInstanceOf( - AbortSignal - ) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect( - execCalls.some( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - ).toBe(true) - const installCommand = execCalls.find((c) => c.includes('npm install')) ?? '' - expect(installCommand).toContain('node_modules/@parcel/watcher') - expect(installCommand).toContain("-name 'watcher-*'") - expect(installCommand).not.toContain("rm -rf 'node_modules/node-pty'") - }) - - it('keeps the caller resets when a repair reconnect has to drop node-pty', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed(makeRepairToolchainSkipExecResponses()) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const reinstall = execCalls.findLast((c) => c.includes('npm install')) ?? '' - expect(reinstall).not.toContain('node-pty@') - expect(reinstall).toContain("rm -rf 'node_modules/node-pty'") - // Dropping the repair's own resets here leaves npm calling the broken watcher up to date. - expect(reinstall).toContain("rm -rf 'node_modules/@parcel/watcher'") - expect(reinstall).toContain("-name 'watcher-*'") - }) - - it('launches an already-installed relay in degraded mode when repair throws', async () => { - // Why: a repair failure on a completed dir must not block the connection — relay still serves fs/git/preflight; next reconnect retries. - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - BOTH_NATIVE_DEPS_MISSING_PROBE, // health probe: require() names both deps - BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe after lock - '', // SFTP-namespace install-owner marker (repair) - { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) - 'DEAD', - '', // remote credential generation after degraded repair - 'READY' - ]) - - // Deploy must resolve (degraded), not reject. - await deployAndLaunchRelay(conn) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('launching degraded'))).toBe(true) - }) - - it('retains the repair lock when remote command termination is unconfirmed', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/u') - .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) - .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) - .mockResolvedValueOnce('') // SFTP-namespace install-owner marker (repair) - .mockRejectedValueOnce( - Object.assign(new Error('npm termination was not confirmed'), { - sshChannelCloseConfirmed: false - }) - ) - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('') // remote credential generation after degraded repair - .mockResolvedValueOnce('READY') - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((message) => message.includes('launching degraded'))).toBe(true) - }) - - it('retains the first-install lock when an aborted npm install has unconfirmed teardown', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - let installSignal: AbortSignal | undefined - vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { - expect(command).toContain('npm install') - installSignal = options?.signal - return new Promise((_resolve, reject) => { - installSignal?.addEventListener( - 'abort', - () => { - // Mirrors execCommand's bounded close grace when ssh2 never confirms npm stopped. - setTimeout( - () => - reject( - Object.assign(new Error('npm teardown remained unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ), - 5_000 - ) - }, - { once: true } - ) - }) - }) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(installSignal).toBeDefined()) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(5_000) - const result = await deploy - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toContain('Relay deployment timed out') - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('does not finalize or release a first-install lock after unconfirmed rebuild teardown', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install - '', // chmod prebuilds - 'MISSING', - 'rebuild diagnostics', - '' // remove probe diagnostics - ]) - vi.mocked(execCommand).mockRejectedValueOnce( - Object.assign(new Error('rebuild termination was not confirmed'), { - sshChannelCloseConfirmed: false - }) - ) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow( - 'rebuild termination was not confirmed' - ) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - }) - - it('retains the first-install lock when an aborted rebuild has unconfirmed teardown', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install - '', // chmod prebuilds - 'MISSING', - 'rebuild diagnostics', - '' // remove probe diagnostics - ]) - let rebuildSignal: AbortSignal | undefined - vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { - expect(command).toContain('npm rebuild') - rebuildSignal = options?.signal - return new Promise((_resolve, reject) => { - rebuildSignal?.addEventListener( - 'abort', - () => { - setTimeout( - () => - reject( - Object.assign(new Error('rebuild teardown remained unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ), - 5_000 - ) - }, - { once: true } - ) - }) - }) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(rebuildSignal).toBeDefined()) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(5_000) - const result = await deploy - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toContain('Relay deployment timed out') - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it.each(['busy', 'error'] as const)('launches degraded when lock is %s', async (lockResult) => { - // Why: lock contention/wedge must not block a completed relay from launching in degraded mode. - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - BOTH_NATIVE_DEPS_MISSING_PROBE, - 'DEAD', - '', // remote credential generation without a namespace marker - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes(`repair lock is ${lockResult}`))).toBe(true) - }) - - it('loads native bindings when checking whether a completed relay needs repair', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-OK', - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - const healthProbe = vi - .mocked(execCommand) - .mock.calls.map(([, c]) => c) - .find((c) => c.includes('ORCA-NATIVE-DEPS-OK')) - expect(healthProbe).toContain('require("node-pty")') - expect(healthProbe).toContain('loadNativeModule') - expect(healthProbe).toContain('require("@parcel/watcher")') - expect(healthProbe).not.toContain('require.resolve') - }) - - it('does not mutate an existing relay dir when required native deps are present', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-OK', - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled() - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts deleted file mode 100644 index 43ac50dbf524..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -// Why: the repair path used to map ANY probe failure to "all deps missing", so one dropped exec -// channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are -// ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { - BOTH_NATIVE_DEPS_MISSING_PROBE, - decodePowerShellCommand, - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' - -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" -const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" - -describe('native-deps repair probe verdicts', () => { - const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } - let warnSpy: ReturnType - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const key of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[key] - } - for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[key] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of execResponses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - function execCommands(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => command) - } - - function warnings(): string[] { - return warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - } - - it('launches an intact relay when the health probe never answers', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - { reject: 'SSH channel closed unexpectedly' }, // health probe: unverifiable, not MISSING - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - // Assert the repair-avoidance facts before the launch outcome so a regression names the defect - // rather than the fixture drift that follows from an unexpected repair. - const outcome = await deployAndLaunchRelay(conn).then( - (result) => result, - (err: Error) => err - ) - - const commands = execCommands() - expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( - false - ) - // Why: the wrongful rebuild used to be the only visible symptom of a dropped exec channel. - expect( - warnings().some((message) => message.includes('Native deps probe unanswered')), - 'an unanswered probe must still leave a trace' - ).toBe(true) - expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) - expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) - expect(commands.some((command) => command.includes('npm install'))).toBe(false) - // Exactly one probe: an unverifiable answer must not fall through to the locked re-probe. - expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) - }) - - it('leaves node_modules intact when the probe answers without naming a dep', async () => { - // The bare `MISSING` a `|| echo MISSING` subshell emits when node never reached the script - // (bad NODE_OPTIONS, OOM kill, exit 127). The shell answered; the answer is not about the deps. - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'MISSING', // answered, no marker line: nothing here names a dep - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - const outcome = await deployAndLaunchRelay(conn).then( - (result) => result, - (err: Error) => err - ) - - const commands = execCommands() - expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( - false - ) - expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) - expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) - expect(commands.some((command) => command.includes('npm install'))).toBe(false) - // One probe only: an unverifiable answer must not fall through to the locked re-probe. - expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(outcome, 'an unparseable answer must not abort the connection').not.toBeInstanceOf(Error) - expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( - true - ) - }) - - it('carries the probe stderr into the unparseable-answer warning', async () => { - const conn = makeMockConnection(sftpCapture) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/u') - .mockImplementationOnce((_conn, _command, options) => { - options?.onStderr?.('node: --inspect-brk is not allowed in NODE_OPTIONS') - return Promise.resolve('MISSING') - }) - feed(['', 'DEAD', '', 'READY']) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - // Why: `2>/dev/null` used to drop the one line that says which host config broke node. - expect( - warnings().find((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE')) - ).toContain('not allowed in NODE_OPTIONS') - }) - - it('still resets both deps when the probe names both', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - BOTH_NATIVE_DEPS_MISSING_PROBE, // answered: both deps are genuinely broken - BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the repair lock - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain(NODE_PTY_RESET) - expect(install).toContain(WATCHER_RESET) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('leaves a Windows relay intact when its probe answers without naming a dep', async () => { - // The PowerShell branch has the same hole: `try { & node -e ... } catch { 'MISSING' }` prints - // nothing when node exits non-zero without reaching the script. - vi.mocked(parseUnameToRelayPlatform).mockReturnValueOnce('win32-x64') - vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Windows AMD64', - 'C:\\Users\\u', - '', // health probe: PowerShell swallowed the native failure, so nothing names a dep - '', // no persisted active pipe marker - 'WAITING', // initial pipe probe - '', // WMI relay launch - 'READY', // readiness poll - '' // persist active pipe marker - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const scripts = execCommands().map((command) => decodePowerShellCommand(command) ?? command) - expect(scripts.some((script) => script.includes('node_modules/node-pty'))).toBe(false) - expect(scripts.some((script) => script.includes('node_modules/@parcel/watcher'))).toBe(false) - expect(scripts.some((script) => script.includes('npm install'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( - true - ) - }) - - it('resets only the dep the probe names', async () => { - const conn = makeMockConnection(sftpCapture) - const watcherMissing = 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING' - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - watcherMissing, - watcherMissing, // re-probe under the repair lock - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain(WATCHER_RESET) - expect(install).not.toContain(NODE_PTY_RESET) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('skips repair entirely when the probe answers OK', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-OK', - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - expect(execCommands().some((command) => command.includes('npm install'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('keeps the answered reset scope when the locked re-probe cannot answer', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // answered: only the watcher is broken - { reject: 'SSH channel closed unexpectedly' }, // re-probe under the lock: unverifiable - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain(WATCHER_RESET) - // The unanswered re-probe must not widen the reset to a dep no probe ever reported broken. - expect(install).not.toContain(NODE_PTY_RESET) - }) -}) diff --git a/src/main/ssh/ssh-relay-node-headers.test.ts b/src/main/ssh/ssh-relay-node-headers.test.ts deleted file mode 100644 index 84e9016738b1..000000000000 --- a/src/main/ssh/ssh-relay-node-headers.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { - chmodSync, - copyFileSync, - mkdtempSync, - mkdirSync, - rmSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import process from 'node:process' -import { afterEach, describe, expect, it } from 'vitest' -import { exportLocalNodeHeadersPrefix, localNodeHeadersFromOutput } from './ssh-relay-node-headers' - -const POSIX = process.platform !== 'win32' - -/** Runs the prefix under /bin/sh exactly as the relay does, then prints what node-gyp would see. */ -function runPrefix(nodePath: string): { - nodedir: string - pkgNodedir: string - marker: string | null | undefined -} { - const script = `${exportLocalNodeHeadersPrefix(nodePath)}printf '%s\\n%s\\n' "$npm_config_nodedir" "$npm_package_config_node_gyp_nodedir"` - const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' }) - expect(result.status).toBe(0) - const marker = localNodeHeadersFromOutput(result.stdout) - const [nodedir = '', pkgNodedir = ''] = result.stdout - .split('\n') - .filter((line) => !line.startsWith('ORCA-NODE-HEADERS:')) - return { nodedir, pkgNodedir, marker } -} - -/** A fake `/bin/node` whose `include/node/node_version.h` claims `version`. */ -function fakeNodePrefix(root: string, version: string): string { - const prefix = join(root, 'prefix') - mkdirSync(join(prefix, 'bin'), { recursive: true }) - mkdirSync(join(prefix, 'include', 'node'), { recursive: true }) - const [major, minor, patch] = version.split('.') - writeFileSync( - join(prefix, 'include', 'node', 'node_version.h'), - `#define NODE_MAJOR_VERSION ${major}\n#define NODE_MINOR_VERSION ${minor}\n#define NODE_PATCH_VERSION ${patch}\n` - ) - // Why a symlink to the real binary: the probe reads process.execPath, which Node resolves - // through symlinks -- so this stands in for `/usr/bin/node -> /opt/node/bin/node` shims too. - symlinkSync(process.execPath, join(prefix, 'bin', 'node')) - return join(prefix, 'bin', 'node') -} - -describe.skipIf(!POSIX)('exportLocalNodeHeadersPrefix', () => { - const roots: string[] = [] - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - it('exports nodedir when the running Node ships headers for its own version', () => { - // The test runner's Node is an official build, so its prefix has include/node. - const prefix = dirname(dirname(process.execPath)) - const { nodedir, pkgNodedir, marker } = runPrefix(process.execPath) - expect(nodedir).toBe(prefix) - expect(pkgNodedir).toBe(prefix) - expect(marker).toBe(prefix) - }) - - it('leaves nodedir unset when the shipped headers are for another Node version', () => { - // A symlinked node resolves execPath to the real binary, whose prefix is the real one; so - // to stage a mismatch the probe must run a node whose execPath lands in the fake prefix. - // A copy does that. - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const prefix = join(root, 'prefix') - mkdirSync(join(prefix, 'bin'), { recursive: true }) - mkdirSync(join(prefix, 'include', 'node'), { recursive: true }) - writeFileSync( - join(prefix, 'include', 'node', 'node_version.h'), - '#define NODE_MAJOR_VERSION 1\n#define NODE_MINOR_VERSION 0\n#define NODE_PATCH_VERSION 0\n' - ) - const copied = join(prefix, 'bin', 'node') - copyFileSync(process.execPath, copied) - chmodSync(copied, 0o755) - const { nodedir, pkgNodedir, marker } = runPrefix(copied) - expect(nodedir).toBe('') - expect(pkgNodedir).toBe('') - expect(marker).toBeNull() - }) - - it('leaves nodedir unset when the prefix has no headers at all', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const copied = join(root, 'bin', 'node') - mkdirSync(dirname(copied), { recursive: true }) - copyFileSync(process.execPath, copied) - chmodSync(copied, 0o755) - const { nodedir } = runPrefix(copied) - expect(nodedir).toBe('') - }) - - it('follows a symlinked node to the install that owns the headers', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const shim = fakeNodePrefix(root, '0.0.0') - // The shim's own fake headers are ignored: execPath resolves to the real binary, and the - // real prefix's headers are the ones that match. - const { nodedir } = runPrefix(shim) - expect(nodedir).toBe(dirname(dirname(process.execPath))) - }) - - it('clears an inherited nodedir when the probe finds no matching headers', () => { - // A remote profile's stale nodedir must not survive past the version check. - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const copied = join(root, 'bin', 'node') - mkdirSync(dirname(copied), { recursive: true }) - copyFileSync(process.execPath, copied) - chmodSync(copied, 0o755) - const script = `${exportLocalNodeHeadersPrefix(copied)}printf '%s|%s|%s' "$npm_config_nodedir" "$NPM_CONFIG_NODEDIR" "$npm_package_config_node_gyp_nodedir"` - const result = spawnSync('/bin/sh', ['-c', script], { - encoding: 'utf8', - env: { - ...process.env, - npm_config_nodedir: '/usr/stale-headers', - NPM_CONFIG_NODEDIR: '/usr/stale-headers', - npm_package_config_node_gyp_nodedir: '/usr/stale-headers' - } - }) - expect(result.status).toBe(0) - expect(result.stdout.split('\n').at(-1)).toBe('||') - }) - - it('does not fail the command line when node itself cannot run', () => { - const script = `${exportLocalNodeHeadersPrefix('/nonexistent/node')}echo "after:$npm_config_nodedir"` - const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' }) - expect(result.status).toBe(0) - expect(result.stdout.trim()).toBe('ORCA-NODE-HEADERS:none\nafter:') - }) -}) - -describe('localNodeHeadersFromOutput', () => { - it('reads the host answer, not the copy of the marker echo quoted in an exec-failure head', () => { - // The real shape: execCommand quotes the whole command line, prefix included, before the output. - const command = `export PATH='/usr/local/bin':$PATH && cd '/root/.orca-remote/relay-x' && ${exportLocalNodeHeadersPrefix('/usr/local/bin/node')}npm install node-pty 2>&1` - const failed = (hostOutput: string): string => - `Command "${command}" failed (exit 1): ${hostOutput}` - expect( - localNodeHeadersFromOutput(failed('ORCA-NODE-HEADERS:none\ngyp ERR! configure error')) - ).toBeNull() - expect( - localNodeHeadersFromOutput(failed('ORCA-NODE-HEADERS:/usr/local\ngyp ERR! configure error')) - ).toBe('/usr/local') - // No host output at all after the head: the command copy alone must not count as a marker. - expect(localNodeHeadersFromOutput(failed(''))).toBeUndefined() - }) - - it('distinguishes an exported dir, an explicit none, and no marker at all', () => { - expect(localNodeHeadersFromOutput('x\nORCA-NODE-HEADERS:/usr/local\ngyp ERR!')).toBe( - '/usr/local' - ) - expect(localNodeHeadersFromOutput('ORCA-NODE-HEADERS:none\ngyp ERR!')).toBeNull() - expect(localNodeHeadersFromOutput('gyp ERR! only')).toBeUndefined() - }) -}) diff --git a/src/main/ssh/ssh-relay-node-headers.ts b/src/main/ssh/ssh-relay-node-headers.ts deleted file mode 100644 index a590bd40fca0..000000000000 --- a/src/main/ssh/ssh-relay-node-headers.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Point node-gyp at the headers the host's Node install already ships, so compiling node-pty - * needs nothing from nodejs.org. - * - * Why: node-pty has no Linux prebuild, so every Linux relay compiles it, and node-gyp's default - * is to download `node-v-headers.tar.gz` before configuring. Every official Node build, and - * every version manager that unpacks one (nvm, fnm, volta, mise, n), already has those exact - * headers at `/include/node`. The download was the only step that needed the internet, - * so a firewalled host failed with ECONNREFUSED on work that never had to happen (STA-6674). - * - * Why both variables: node-gyp >= 11.4 prefers `npm_package_config_node_gyp_` and npm 11+ - * warns that arbitrary `npm_config_` is deprecated, but node-gyp 10 (bundled with Node 20) - * reads only `npm_config_`. Both together cover every Node the relay runs on. - * - * Why the version check: node-gyp trusts `nodedir` blindly, so a distro `/usr/include/node` left - * by an older headers package would be compiled against as-is. Whether that binding then misbehaves - * is not established (one measured run loaded a node-20-header build under node 24); refusing is - * the conservative default. A mismatch leaves the variables unset, which is today's path. - */ -import { shellEscape } from './ssh-connection-utils' - -/** Shell variable the probe answers into; namespaced so it cannot collide with npm's own. */ -const NODEDIR_SHELL_VAR = 'ORCA_NODE_HEADERS_DIR' - -/** - * Prints the running Node's install prefix when `/include/node/node_version.h` matches - * `process.versions.node`, and nothing otherwise. `process.execPath` is symlink-resolved, so a - * `/usr/bin/node` -> `/opt/node/bin/node` shim still finds `/opt/node/include`. - */ -export const LOCAL_NODE_HEADERS_PROBE_JS = [ - 'const p=require("path"),f=require("fs");', - 'const d=p.dirname(p.dirname(process.execPath));', - 'try{', - 'const h=f.readFileSync(p.join(d,"include","node","node_version.h"),"utf8");', - 'const v=["MAJOR","MINOR","PATCH"].map(k=>(h.match(new RegExp("#define NODE_"+k+"_VERSION ([0-9]+)"))||[])[1]).join(".");', - 'if(v===process.versions.node)process.stdout.write(d)', - '}catch{}' -].join('') - -/** - * Stdout marker naming what the probe found, printed before the compile so the answer is in the - * captured output of any failure that follows. `none` means no matching local headers. - */ -export const LOCAL_NODE_HEADERS_MARKER_PREFIX = 'ORCA-NODE-HEADERS:' - -/** - * POSIX-sh prefix (`...; `) that exports node-gyp's `nodedir` for the rest of the command line - * when the host's Node ships matching headers. Prepend to any command that may compile node-pty: - * `npm install`, `npm rebuild`, and the cloexec patch (its `npm rebuild` inherits the env). - */ -export function exportLocalNodeHeadersPrefix(nodePath: string): string { - const probe = `${shellEscape(nodePath)} -e ${shellEscape(LOCAL_NODE_HEADERS_PROBE_JS)} 2>/dev/null` - // Why the unset: a remote profile can already export a nodedir (a stale distro header dir), in - // either case npm accepts. Left alone it would bypass the version check above and compile - // against those headers. Deliberately env only: a `nodedir=` in ~/.npmrc is not reachable from here - // -- npm ignores an empty env override, and a CLI `--nodedir=` would also override the good - // export -- so an npmrc setting stays the operator's, as it was before this prefix existed. - return ( - `${NODEDIR_SHELL_VAR}=$(${probe}); ` + - `unset npm_config_nodedir NPM_CONFIG_NODEDIR npm_package_config_node_gyp_nodedir; ` + - `if [ -n "$${NODEDIR_SHELL_VAR}" ]; then ` + - `export npm_config_nodedir="$${NODEDIR_SHELL_VAR}" npm_package_config_node_gyp_nodedir="$${NODEDIR_SHELL_VAR}"; ` + - `fi; ` + - `echo "${LOCAL_NODE_HEADERS_MARKER_PREFIX}\${${NODEDIR_SHELL_VAR}:-none}"; ` - ) -} - -/** - * The headers dir the prefix exported, `null` when it found none, or `undefined` when the - * marker is absent (output truncated, or the command never reached the prefix). - */ -export function localNodeHeadersFromOutput(output: string): string | null | undefined { - // Why the head is stripped first: a failed exec's message is `Command "" failed - // (exit N): `, and quotes this prefix verbatim -- including the marker's - // `echo`. Scanning from the start would match that copy and return `${ORCA_NODE_HEADERS_DIR:- - // none}"...` as a "dir". Only what follows the head is the host's answer. - const head = output.match(EXEC_FAILURE_HEAD_RE) - const hostOutput = head ? output.slice(head[0].length) : output - // First match, not last: the host's own line comes first, and later lines are npm/gyp output - // that must not be able to spoof it. - for (const line of hostOutput.split(/\r?\n/)) { - const at = line.indexOf(LOCAL_NODE_HEADERS_MARKER_PREFIX) - if (at === -1) { - continue - } - const dir = line.slice(at + LOCAL_NODE_HEADERS_MARKER_PREFIX.length).trim() - return dir === 'none' || dir === '' ? null : dir - } - return undefined -} - -/** - * `Command "" failed (exit N): ` -- see ssh-relay-exec-command.ts. - * Lazy `[\s\S]*?` is safe: it stops at the first `" failed (exit N): `, and no command this - * module builds contains that literal, so the match cannot end early inside the command. - */ -const EXEC_FAILURE_HEAD_RE = /^Command "[\s\S]*?" failed \(exit -?\d+\): / diff --git a/src/main/ssh/ssh-relay-node-pty-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-repair.test.ts deleted file mode 100644 index 7c1b18002595..000000000000 --- a/src/main/ssh/ssh-relay-node-pty-repair.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Why: the once-only ledger is the whole safety story here — an unbounded rebuild loop against a -// remote is worse than the bug it chases, so every gate that stops one gets a test. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - forgetRelayNodePtyRepairs, - recoverRelayNodePtyForSpawn, - relayNodePtyRepairAttempts -} from './ssh-relay-node-pty-repair' -import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' - -const HOST: TerminalUnavailableCause['host'] = { - platform: 'linux', - arch: 'x64', - libc: 'glibc', - glibcVersion: '2.31', - nodeAbi: '115', - nodeVersion: 'v20.11.0' -} - -function cause(overrides: Partial = {}): TerminalUnavailableCause { - return { - status: 'blocked', - reason: 'abi_mismatch', - detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', - repairable: true, - host: HOST, - ...overrides - } -} - -describe('recoverRelayNodePtyForSpawn', () => { - const TARGET = 'host-a' - let warnSpy: ReturnType - - beforeEach(() => { - forgetRelayNodePtyRepairs(TARGET) - forgetRelayNodePtyRepairs('host-b') - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - forgetRelayNodePtyRepairs(TARGET) - forgetRelayNodePtyRepairs('host-b') - }) - - function harness(overrides: { hasLivePtys?: boolean; reconnect?: () => Promise } = {}) { - const reconnect = vi.fn(overrides.reconnect ?? (async () => {})) - const repaired = { name: 'post-repair-provider' } - const resolveProvider = vi.fn(() => repaired) - return { - reconnect, - resolveProvider, - repaired, - run: (c: TerminalUnavailableCause | null) => - recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: c, - hasLivePtys: () => overrides.hasLivePtys === true, - reconnect, - resolveProvider - }) - } - } - - it('repairs a repairable cause with exactly one reconnect and hands back the new provider', async () => { - const h = harness() - - const result = await h.run(cause()) - - expect(result.outcome).toBe('repaired') - expect(result.provider).toBe(h.repaired) - expect(h.reconnect).toHaveBeenCalledTimes(1) - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual(['abi_mismatch']) - }) - - it('does not repair a second time for the same cause on the same host', async () => { - const first = harness() - await first.run(cause()) - const second = harness() - - const result = await second.run(cause()) - - expect(result.outcome).toBe('already-attempted') - expect(result.provider).toBeNull() - expect(second.reconnect).not.toHaveBeenCalled() - }) - - it('spends the attempt even when the repair reconnect fails, so it cannot loop', async () => { - const failing = harness({ - reconnect: async () => { - throw new Error('relay repair lock is wedged') - } - }) - - const first = await failing.run(cause()) - expect(first.outcome).toBe('reconnect-failed') - expect(first.provider).toBeNull() - - const second = harness() - const retry = await second.run(cause()) - - expect(retry.outcome).toBe('already-attempted') - expect(second.reconnect).not.toHaveBeenCalled() - }) - - it('keeps the ledger per host, so a second host still gets its one attempt', async () => { - const h = harness() - await h.run(cause()) - const other = vi.fn(async () => {}) - - const result = await recoverRelayNodePtyForSpawn({ - targetId: 'host-b', - cause: cause(), - hasLivePtys: () => false, - reconnect: other, - resolveProvider: () => ({}) - }) - - expect(result.outcome).toBe('repaired') - expect(other).toHaveBeenCalledTimes(1) - }) - - it('keeps the ledger per reason, so a different proved fault still gets its one attempt', async () => { - const h = harness() - await h.run(cause()) - const second = harness() - - const result = await second.run(cause({ reason: 'arch_mismatch' })) - - expect(result.outcome).toBe('repaired') - expect([...relayNodePtyRepairAttempts(TARGET)].sort()).toEqual([ - 'abi_mismatch', - 'arch_mismatch' - ]) - }) - - it('never repairs an unverifiable cause, whatever the peer claims about repairability', async () => { - const h = harness() - - // Why repairable:true here: #14830 is exactly a peer flag believed over the status. - const result = await h.run(cause({ status: 'unverifiable', repairable: true })) - - expect(result.outcome).toBe('not-repairable') - expect(h.reconnect).not.toHaveBeenCalled() - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) - }) - - it('never repairs a toolchain_missing cause — the rebuild needs the missing compiler', async () => { - const h = harness() - - const result = await h.run(cause({ reason: 'toolchain_missing', repairable: false })) - - expect(result.outcome).toBe('not-repairable') - expect(h.reconnect).not.toHaveBeenCalled() - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) - }) - - it('never repairs when the relay published no cause at all', async () => { - const h = harness() - - const result = await h.run(null) - - expect(result.outcome).toBe('not-repairable') - expect(h.reconnect).not.toHaveBeenCalled() - }) - - it('does not rebuild under live PTYs, and does not spend the attempt doing so', async () => { - const live = harness({ hasLivePtys: true }) - - const blocked = await live.run(cause()) - expect(blocked.outcome).toBe('ptys-live') - expect(live.reconnect).not.toHaveBeenCalled() - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) - - const idle = harness() - expect((await idle.run(cause())).outcome).toBe('repaired') - }) - - it('withholds the retry when the reconnect produced no provider', async () => { - const reconnect = vi.fn(async () => {}) - - const result = await recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: cause(), - hasLivePtys: () => false, - reconnect, - resolveProvider: () => null - }) - - expect(result.outcome).toBe('no-provider') - expect(result.provider).toBeNull() - expect(reconnect).toHaveBeenCalledTimes(1) - }) - - it('gives a host a fresh attempt only after an explicit disconnect', async () => { - await harness().run(cause()) - forgetRelayNodePtyRepairs(TARGET) - const afterDisconnect = harness() - - expect((await afterDisconnect.run(cause())).outcome).toBe('repaired') - }) -}) diff --git a/src/main/ssh/ssh-relay-node-pty-repair.ts b/src/main/ssh/ssh-relay-node-pty-repair.ts deleted file mode 100644 index 149b6c486cbb..000000000000 --- a/src/main/ssh/ssh-relay-node-pty-repair.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Turning a spawn-time "remote terminals are unavailable" into an actual repair. - * - * The fault is proved on the relay at spawn time; the only thing that can fix it — - * `repairInstalledNativeDeps` in ssh-relay-deploy.ts — runs on the client during deploy, under - * `tryAcquireRelayRepairLock`. So the recovery here is deliberately indirect: it does not touch - * the remote `node_modules` itself, it drives one relay reconnect and lets the locked deploy path - * do the rebuild. All `node_modules` mutation stays behind that lock. - * - * The invariant that matters more than the recovery: **at most one repair per host per reason.** - * A rebuild loop against a remote is worse than the bug it is chasing, so the attempt is recorded - * before the reconnect starts, not after it succeeds. A repair that ran and failed is still an - * attempt, and this host will render the relay's message from then on. - */ -import { - mayRepairFromCause, - type TerminalUnavailableCause -} from '../../shared/terminal-unavailable-cause' - -/** targetId -> the cause reasons already spent on this host, for the life of the session. */ -const attemptedRepairsByTarget = new Map>() - -export type RelayNodePtyRepairOutcome = - /** The cause is not proved-and-rebuildable; render the relay's message. */ - | 'not-repairable' - /** This host already spent its one attempt on this reason. */ - | 'already-attempted' - /** The relay is still serving PTYs, so a rebuild under it is not accounted for. */ - | 'ptys-live' - /** No reconnect was possible, or it failed; the attempt is still spent. */ - | 'reconnect-failed' - /** Reconnected, but no provider came back to retry on. */ - | 'no-provider' - | 'repaired' - -/** Forget a host's spent attempts. Disconnect is user action, so it may earn a fresh one. */ -export function forgetRelayNodePtyRepairs(targetId: string): void { - attemptedRepairsByTarget.delete(targetId) -} - -/** Test/diagnostic view of the ledger. */ -export function relayNodePtyRepairAttempts(targetId: string): ReadonlySet { - return attemptedRepairsByTarget.get(targetId) ?? new Set() -} - -/** False when this host has already spent its attempt on this reason. Marks on success. */ -function claimRepairAttempt(targetId: string, reason: string): boolean { - const spent = attemptedRepairsByTarget.get(targetId) - if (spent) { - if (spent.has(reason)) { - return false - } - spent.add(reason) - return true - } - attemptedRepairsByTarget.set(targetId, new Set([reason])) - return true -} - -export type RelayNodePtyRepairRequest = { - targetId: string - /** Already parsed and schema-validated; null when the relay published no cause. */ - cause: TerminalUnavailableCause | null - /** Whether this client still holds live PTYs on the relay about to be rebuilt. */ - hasLivePtys: () => boolean - /** One relay reconnect, which runs the locked `repairInstalledNativeDeps`. */ - reconnect: () => Promise - /** The provider registered after the reconnect — never the one that failed. */ - resolveProvider: () => TProvider | null -} - -/** - * Drive one repair for a failed spawn, returning the provider to retry on. - * - * Gates on `mayRepairFromCause`, not on the peer's `repairable` flag: an `unverifiable` cause - * proves nothing and must never trigger a rebuild (#14830, docs/reference/ssh-execution-boundary.md). - */ -export async function recoverRelayNodePtyForSpawn( - request: RelayNodePtyRepairRequest -): Promise<{ outcome: RelayNodePtyRepairOutcome; provider: TProvider | null }> { - const { targetId, cause } = request - if (!mayRepairFromCause(cause) || !cause) { - return { outcome: 'not-repairable', provider: null } - } - // Why check before claiming: a live PTY means the rebuild's blast radius is unaccounted for, and - // that is a reason to wait, not a spent attempt. Costs nothing remote, so it cannot loop. - if (request.hasLivePtys()) { - console.warn( - `[ssh-relay-repair] Not rebuilding node-pty on ${targetId} for ${cause.reason}: the relay is still serving PTYs` - ) - return { outcome: 'ptys-live', provider: null } - } - if (!claimRepairAttempt(targetId, cause.reason)) { - console.warn( - `[ssh-relay-repair] node-pty repair for ${cause.reason} on ${targetId} already ran; not retrying` - ) - return { outcome: 'already-attempted', provider: null } - } - - console.warn( - `[ssh-relay-repair] Reconnecting ${targetId} once to rebuild node-pty (${cause.reason}): ${cause.detail}` - ) - try { - await request.reconnect() - } catch (error) { - console.warn( - `[ssh-relay-repair] Repair reconnect for ${targetId} failed: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return { outcome: 'reconnect-failed', provider: null } - } - const provider = request.resolveProvider() - return provider ? { outcome: 'repaired', provider } : { outcome: 'no-provider', provider: null } -} diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts deleted file mode 100644 index e43516fe0c58..000000000000 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -// The other half of the #17830 recovery: proof that the reconnect a spawn-time cause triggers is -// the SAME locked deploy repair, not a second rebuild path. `tryAcquireRelayRepairLock` is the only -// thing standing between two clients and a concurrent `node_modules` rewrite, so a lock this path -// cannot take must degrade to the relay's message rather than proceed. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: () => false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { gcOldRelayVersions, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' -import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' -import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' - -const TARGET = 'repair-host' - -const ABI_MISMATCH: TerminalUnavailableCause = { - status: 'blocked', - reason: 'abi_mismatch', - detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', - repairable: true, - host: { - platform: 'linux', - arch: 'x64', - libc: 'glibc', - glibcVersion: '2.31', - nodeAbi: '115', - nodeVersion: 'v20.11.0' - } -} - -// The relay dir is complete but node-pty will not load, which is exactly what the spawn-time cause -// describes. @parcel/watcher is healthy, so only node-pty is reset and rebuilt. -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const NODE_PTY_BROKEN = 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING' - -function repairSucceedsResponses(): ExecResponse[] { - return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - NODE_PTY_BROKEN, // health probe before the lock - NODE_PTY_BROKEN, // re-probe under the repair lock - '', // SFTP-namespace install-owner marker - '', // reset node-pty + npm install - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', // node-pty loads again - '', // rm -f probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ] -} - -function lockUnavailableResponses(): ExecResponse[] { - return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - NODE_PTY_BROKEN, // health probe before the lock - 'DEAD', - 'READY' - ] -} - -describe('spawn-time node-pty repair through the locked deploy path', () => { - let warnSpy: ReturnType - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - sftpCapture.paths.length = 0 - for (const key of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[key] - } - for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[key] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') - forgetRelayNodePtyRepairs(TARGET) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - forgetRelayNodePtyRepairs(TARGET) - }) - - function feed(responses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of responses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - function recover(conn: ReturnType, deploys: { count: number }) { - return recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: ABI_MISMATCH, - hasLivePtys: () => false, - reconnect: async () => { - deploys.count += 1 - await deployAndLaunchRelay(conn) - }, - resolveProvider: () => ({ generation: deploys.count }) - }) - } - - function execCalls(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => String(command)) - } - - it('rebuilds node-pty under the repair lock and returns the post-reconnect provider', async () => { - const conn = makeMockConnection(sftpCapture) - feed(repairSucceedsResponses()) - const deploys = { count: 0 } - - const result = await recover(conn, deploys) - - expect(result.outcome).toBe('repaired') - expect(result.provider).toEqual({ generation: 1 }) - expect(deploys.count).toBe(1) - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - const install = execCalls().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain('npm install') - // The reset is what makes an ABI-mismatched binding recompile instead of being reported up to date. - expect(install).toContain("rm -rf 'node_modules/node-pty'") - }) - - it('does not repair or reconnect a second time for the same cause on the same host', async () => { - const conn = makeMockConnection(sftpCapture) - feed(repairSucceedsResponses()) - const deploys = { count: 0 } - await recover(conn, deploys) - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - vi.mocked(execCommand).mockReset().mockResolvedValue('') - - const second = await recover(conn, deploys) - - expect(second.outcome).toBe('already-attempted') - expect(second.provider).toBeNull() - expect(deploys.count).toBe(1) - expect(execCalls()).toEqual([]) - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - }) - - it.each(['busy', 'error'] as const)( - 'leaves the host untouched and degrades to the relay message when the repair lock is %s', - async (lockResult) => { - const conn = makeMockConnection(sftpCapture) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue(lockResult) - feed(lockUnavailableResponses()) - const deploys = { count: 0 } - - const result = await recover(conn, deploys) - - // The reconnect happened; the rebuild did not, so the retried spawn hits the same relay - // rejection and the user reads today's message. Nothing wrote to node_modules unlocked. - expect(deploys.count).toBe(1) - expect(execCalls().some((command) => command.includes('npm install'))).toBe(false) - expect(execCalls().some((command) => command.includes('node_modules/node-pty'))).toBe(false) - expect(result.outcome).toBe('repaired') - const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnings.some((line) => line.includes(`repair lock is ${lockResult}`))).toBe(true) - } - ) - - it('never reaches the deploy path for an unverifiable cause', async () => { - const conn = makeMockConnection(sftpCapture) - const deploys = { count: 0 } - - const result = await recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: { ...ABI_MISMATCH, status: 'unverifiable' }, - hasLivePtys: () => false, - reconnect: async () => { - deploys.count += 1 - await deployAndLaunchRelay(conn) - }, - resolveProvider: () => ({ generation: deploys.count }) - }) - - expect(result.outcome).toBe('not-repairable') - expect(deploys.count).toBe(0) - expect(vi.mocked(tryAcquireRelayRepairLock)).not.toHaveBeenCalled() - expect(execCalls()).toEqual([]) - }) - - it('never reaches the deploy path for a toolchain_missing cause', async () => { - const conn = makeMockConnection(sftpCapture) - const deploys = { count: 0 } - - const result = await recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: { ...ABI_MISMATCH, reason: 'toolchain_missing', repairable: false }, - hasLivePtys: () => false, - reconnect: async () => { - deploys.count += 1 - await deployAndLaunchRelay(conn) - }, - resolveProvider: () => ({ generation: deploys.count }) - }) - - expect(result.outcome).toBe('not-repairable') - expect(deploys.count).toBe(0) - expect(execCalls()).toEqual([]) - }) -}) diff --git a/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts b/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts deleted file mode 100644 index c5700c991c19..000000000000 --- a/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Why this exists (STA-6674): a Linux host whose only unreachable endpoint is nodejs.org could -// not run a relay. node-pty ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp -// downloads `node-v-headers.tar.gz` unless told the host already has the headers -- which -// every official Node install does, at `/include/node`. This drives the real deploy at a -// Docker sshd whose nodejs.org resolves to 127.0.0.1 (ECONNREFUSED, exactly what the user saw). -// -// Run: ORCA_REVIEW_SSH_OFFLINE_HEADERS=1 pnpm test src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts -// Needs Docker and `pnpm build:relay`. ORCA_REVIEW_SSH_NODE_IMAGE picks the Node image -// (default node:24.12.0-bookworm, the user's version); ORCA_REVIEW_SSH_TARGET_HOST overrides -// the address the app connects to (default 127.0.0.1). -import { execFileSync, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { connect } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { SshConnection } from './ssh-connection' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import type { SshTarget } from '../../shared/ssh-types' - -const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_OFFLINE_HEADERS === '1' -const NODE_IMAGE = process.env.ORCA_REVIEW_SSH_NODE_IMAGE ?? 'node:24.12.0-bookworm' -const TARGET_HOST = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '127.0.0.1' - -type TargetFixture = { - containerName: string - identityFile: string - port: number - tempDir: string -} - -function run(command: string, args: string[], timeout = 30_000, input?: string): string { - return execFileSync(command, args, { - encoding: 'utf8', - stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], - timeout, - input - }).trim() -} - -function dockerExec(fixture: TargetFixture, command: string): string { - return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000) -} - -async function startTarget(): Promise { - const image = `orca-review-offline-headers:${NODE_IMAGE.replace(/[^A-Za-z0-9_.-]/g, '-')}` - run( - 'docker', - ['build', '-q', '-t', image, '-'], - 600_000, - [ - `FROM ${NODE_IMAGE}`, - 'RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server git && rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh', - '' - ].join('\n') - ) - const tempDir = mkdtempSync(join(tmpdir(), 'orca-offline-headers-ssh-')) - const identityFile = join(tempDir, 'id_ed25519') - run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) - const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() - const containerName = `orca-offline-headers-${randomUUID().slice(0, 12)}` - // Why a refused connection and not a dropped one: a timeout takes node-gyp's retry path and - // burns the deploy budget; the user's host refused, and that is the path under test. - run( - 'docker', - [ - 'run', - '-d', - '--name', - containerName, - '--add-host', - 'nodejs.org:127.0.0.1', - '-p', - '0.0.0.0::22', - '-e', - `AUTHORIZED_KEY=${publicKey}`, - image, - 'bash', - '-lc', - 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' - ], - 120_000 - ) - const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) - // `docker run -d` returns before sshd binds; connect() against a closed port is a flake. - await waitForSshBanner(port) - return { containerName, identityFile, port, tempDir } -} - -/** Resolves once sshd answers with its banner on the mapped port, or throws after the deadline. */ -async function waitForSshBanner(port: number, deadlineMs = 60_000): Promise { - const deadline = Date.now() + deadlineMs - for (;;) { - const gotBanner = await new Promise((resolve) => { - const socket = connect({ host: TARGET_HOST, port }) - const done = (value: boolean): void => { - socket.destroy() - resolve(value) - } - socket.setTimeout(2_000, () => done(false)) - socket.once('data', (chunk) => done(chunk.toString('utf8').startsWith('SSH-'))) - socket.once('error', () => done(false)) - }) - if (gotBanner) { - return - } - if (Date.now() > deadline) { - throw new Error(`sshd on port ${port} did not answer within ${deadlineMs / 1000}s`) - } - await new Promise((resolve) => setTimeout(resolve, 500)) - } -} - -function stopTarget(fixture: TargetFixture | null): void { - if (!fixture) { - return - } - spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 }) - rmSync(fixture.tempDir, { recursive: true, force: true }) -} - -function createConnection(fixture: TargetFixture): SshConnection { - const target: SshTarget = { - id: `offline-headers-${randomUUID()}`, - label: 'Offline node headers Docker SSH target', - source: 'manual', - host: TARGET_HOST, - port: fixture.port, - username: 'root', - identityFile: fixture.identityFile, - identitiesOnly: true - } - return new SshConnection(target, { onStateChange: vi.fn() }) -} - -describe.skipIf(!RUN_REVIEW_ORACLE)( - 'SSH relay deploy on a host that cannot reach nodejs.org', - () => { - let fixture: TargetFixture | null = null - - beforeAll(async () => { - fixture = await startTarget() - }, 900_000) - - afterAll(() => { - stopTarget(fixture) - }) - - it('compiles node-pty from the host Node install headers instead of downloading them', async () => { - const activeFixture = fixture as TargetFixture - expect(dockerExec(activeFixture, 'getent hosts nodejs.org')).toContain('127.0.0.1') - const connection = createConnection(activeFixture) - await connection.connect() - try { - const result = await deployAndLaunchRelay(connection, undefined, 60) - expect(result.remoteRelayDir).toBeTruthy() - - const evidence = dockerExec( - activeFixture, - [ - `cd '${result.remoteRelayDir}'`, - 'test -f node_modules/node-pty/build/Release/pty.node && echo PTY_NODE=built', - 'test -d /root/.cache/node-gyp && echo HEADERS=downloaded || echo HEADERS=local', - `node -e "require('node-pty'); require('@parcel/watcher'); console.log('NATIVE=loadable')"` - ].join('; ') - ) - console.log(`[offline-node-headers] ${NODE_IMAGE}: ${evidence.replace(/\n/g, ' ')}`) - expect(evidence).toContain('PTY_NODE=built') - expect(evidence).toContain('HEADERS=local') - expect(evidence).toContain('NATIVE=loadable') - } finally { - await connection.disconnect() - } - }, 600_000) - - it('names the missing-local-headers cause, not an Orca defect, when the host ships no headers', async () => { - // Same offline host, headers removed and the relay uninstalled so the deploy compiles again. - // This is the shape a review found misreported: the exec-failure message quotes the whole - // command (marker echo included) ahead of the output, and the parser must not read that copy. - const activeFixture = fixture as TargetFixture - dockerExec( - activeFixture, - 'rm -rf /usr/local/include/node /root/.orca-remote /root/.cache/node-gyp' - ) - const connection = createConnection(activeFixture) - await connection.connect() - try { - const error = await deployAndLaunchRelay(connection, undefined, 60).catch((e: Error) => e) - expect(error).toBeInstanceOf(Error) - const message = (error as Error).message - console.log(`[offline-node-headers] ${NODE_IMAGE} no-headers: ${message.split('\n')[0]}`) - expect(message).toContain('no local headers matching its own version') - expect(message).not.toContain('Orca defect') - expect(message).toContain('ECONNREFUSED') - } finally { - await connection.disconnect() - } - }, 600_000) - } -) diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts deleted file mode 100644 index 897aafac33c4..000000000000 --- a/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeRuntimeStore from './orcad-remote-node-runtime' - -const mocks = vi.hoisted(() => ({ - target: vi.fn(), - archive: vi.fn(), - ensure: vi.fn() -})) -vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTargetFacts: mocks.target })) -vi.mock('./pinned-runtime-materializer', () => ({ - materializeNodeRuntimeArchive: mocks.archive -})) -vi.mock('./orcad-remote-node-runtime', async (original) => ({ - ...(await original()), - ensureRemoteOrcadNodeRuntime: mocks.ensure -})) - -import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../shared/node-runtime-pin' -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { preparePinnedNodeForVault } from './ssh-relay-opencode-pinned-node' - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every remote call is mocked; the connection is only passed through. -const conn = {} as SshConnection - -function prepare(platform: 'linux-x64' | 'win32-x64', exec: (command: string) => Promise) { - const relayDir = `${platform === 'win32-x64' ? 'C:/Users/ada' : '/home/ada'}/.orca-remote/relay-build` - return preparePinnedNodeForVault({ - conn, - host: getRemoteHostPlatform(platform), - relayDir, - cacheRoot: '/cache', - signal: new AbortController().signal, - exec, - remote: (operation) => operation() - }) -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('pinned Node for the SSH vault reader', () => { - it.each([ - ['linux-x64', 'linux-x64-glibc', { major: 2, minor: 31 }, '/home/ada', 'bin/node'], - ['win32-x64', 'win32-x64', null, 'C:/Users/ada', 'node.exe'] - ] as const)( - 'installs the official archive into the shared runtimes/ store on %s hosts', - async (platform, target, glibc, home, executableName) => { - const sha = NODE_RUNTIME_ASSETS[target].executableSha256 - const executable = `${home}/.orca-remote/runtimes/node-${sha}/${executableName}` - mocks.target.mockResolvedValue({ target, glibc }) - mocks.ensure.mockImplementation(async (options) => { - expect(options).toMatchObject({ slotDir: `${home}/.orca-remote/relay-build`, target }) - await options.archivePath() - return { executable, transfer: 'uploaded' } - }) - mocks.archive.mockResolvedValue('/cache/node-archive') - - expect(await prepare(platform, vi.fn())).toEqual({ executable, runtimeSha256: sha }) - expect(mocks.archive).toHaveBeenCalledWith(target, '/cache', expect.any(Object)) - } - ) - - it('fetches no archive when the host store already has a verified runtime', async () => { - mocks.target.mockResolvedValue({ target: 'win32-x64', glibc: null }) - mocks.ensure.mockResolvedValue({ executable: 'C:/x/node.exe', transfer: 'cached' }) - await prepare('win32-x64', vi.fn()) - expect(mocks.archive).not.toHaveBeenCalled() - }) - - it('installs the glibc 2.17 compat Node for the reader on a host below the default floor', async () => { - const sha = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 - const executable = `/home/ada/.orca-remote/runtimes/node-${sha}/bin/node` - mocks.target.mockResolvedValue({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 17 } }) - mocks.ensure.mockImplementation(async (options) => { - expect(options).toMatchObject({ target: 'linux-x64-glibc217' }) - await options.archivePath() - return { executable, transfer: 'uploaded' } - }) - mocks.archive.mockResolvedValue('/cache/node-217.tar.gz') - - // The compat sha is the relay dir's store ref, so store GC keeps this runtime. - expect(await prepare('linux-x64', vi.fn())).toEqual({ executable, runtimeSha256: sha }) - expect(mocks.archive).toHaveBeenCalledWith('linux-x64-glibc217', '/cache', expect.any(Object)) - }) - - it('uploads nothing when no Orca-managed Node runs on the host glibc', async () => { - mocks.target.mockResolvedValue({ target: 'linux-arm64-glibc', glibc: { major: 2, minor: 17 } }) - - await expect(prepare('linux-x64', vi.fn())).rejects.toThrow( - "No Orca-managed Node runs on this host's glibc 2.17" - ) - expect(mocks.ensure).not.toHaveBeenCalled() - expect(mocks.archive).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.ts deleted file mode 100644 index a8ae85175b6b..000000000000 --- a/src/main/ssh/ssh-relay-opencode-pinned-node.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' -import type { SshConnection } from './ssh-connection' -import { resolveOrcadDeploymentTargetFacts } from './orcad-deployment-target' -import { ensureRemoteOrcadNodeRuntime, type RemoteRuntimeStep } from './orcad-remote-node-runtime' -import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { pinnedRuntimeTargetForHost } from './ssh-relay-runtime-ladder' - -const DOWNLOAD_TIMEOUT_MS = 180_000 -const downloads = new Map>() - -/** - * The pinned Node for hosts whose own Node cannot read OpenCode's database (design D4a). Every - * host, Windows included, installs it into the shared runtimes/ store as the official archive - * with a `.verified` marker; nothing here touches vault-sqlite/, which old relays' references - * still name. `runtimeSha256` is the ref the relay dir must carry so store GC keeps it. - */ -export async function preparePinnedNodeForVault(options: { - conn: SshConnection - host: RemoteHostPlatform - relayDir: string - cacheRoot?: string - signal: AbortSignal - exec: (command: string) => Promise - remote: RemoteRuntimeStep -}): Promise<{ executable: string; runtimeSha256: string }> { - const { conn, host, signal, exec } = options - const facts = await resolveOrcadDeploymentTargetFacts({ conn, host, signal, exec }) - // Why before any upload: below every runtime's glibc floor the self-test could only fail. - const target = pinnedRuntimeTargetForHost(facts) - if (!target) { - const glibc = facts.glibc ? `${facts.glibc.major}.${facts.glibc.minor}` : 'unknown' - throw new Error(`No Orca-managed Node runs on this host's glibc ${glibc}`) - } - const cacheRoot = - options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') - const { executable } = await ensureRemoteOrcadNodeRuntime({ - conn, - host, - slotDir: options.relayDir, - target, - archivePath: () => cachedArchive(target, cacheRoot, signal), - signal, - remoteStep: options.remote - }) - return { executable, runtimeSha256: pinnedNodeRuntimeAsset(target).executableSha256 } -} - -function cachedArchive( - target: NodeRuntimeTarget, - cacheRoot: string, - signal: AbortSignal -): Promise { - const key = `${cacheRoot}\0${target}` - let pending = downloads.get(key) - if (!pending) { - pending = materializeNodeRuntimeArchive(target, cacheRoot, { - signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) - }).finally(() => downloads.delete(key)) - downloads.set(key, pending) - } - return waitForPromiseWithSignal(pending, signal) -} diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts deleted file mode 100644 index 11832aae7d8d..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { - parseOpenCodeRuntimeResult, - probeOpenCodeNodeSqliteCommand, - publishOpenCodeRuntimeReferenceCommand -} from './ssh-relay-opencode-runtime-commands' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - recoverOneStaleRelayUploadStageCommand, - reserveRelayUploadStageCommand -} from './ssh-relay-upload-stage-commands' - -const host = getRemoteHostPlatform('linux-x64') -const nodePath = process.execPath -const directories: string[] = [] -const runtimeSha = 'a'.repeat(64) -const markerName = '.sftp-namespace-0123456789abcdef0123456789abcdef' - -afterEach(async () => { - await Promise.all( - directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) - ) -}) - -async function directory(): Promise { - const result = await mkdtemp(join(tmpdir(), "orca-runtime spaces ' $-")) - directories.push(result) - return result -} - -async function command(text: string, environment: NodeJS.ProcessEnv = {}) { - return runProcess({ - program: 'sh', - args: ['-c', text], - timeoutMs: 10_000, - env: { ...process.env, OPENCODE_DB: '', XDG_DATA_HOME: '', ...environment } - }) -} - -async function reserveStage(root: string) { - const pool = join(root, '.upload-stages') - const result = await command(reserveRelayUploadStageCommand(host, pool, markerName)) - expect(result.code, result.stderr).toBe(0) - return parseReservedRelayUploadStage(host, pool, markerName, result.stdout) -} - -describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands', () => { - it('runs an actual SQLite read and identifies the executable', async () => { - const home = await directory() - const data = join(home, '.local', 'share', 'opencode') - await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') - const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home)) - expect(result.code).toBe(0) - expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ - status: 'ready', - executable: nodePath - }) - }) - - it('sends a host Node without the SyncDatabase surface to the pinned runtime', async () => { - const home = await directory() - const data = join(home, '.local', 'share', 'opencode') - await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') - // Node 22.13-22.15: DatabaseSync ships, the backup export does not. - const preload = join(home, 'node-22-13-sqlite.cjs') - await writeFile(preload, "delete require('node:sqlite').backup") - const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { - NODE_OPTIONS: `--require ${JSON.stringify(preload)}` - }) - expect(result.code, result.stderr).toBe(0) - expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ status: 'unsupported' }) - }) - - it('ignores the experimental SQLite warning older Node prints to stderr', async () => { - const home = await directory() - const data = join(home, '.local', 'share', 'opencode') - await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') - const preload = join(home, 'experimental-warning.cjs') - await writeFile( - preload, - "process.emitWarning('SQLite is an experimental feature and might change at any time','ExperimentalWarning')" - ) - const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { - NODE_OPTIONS: `--require ${JSON.stringify(preload)}` - }) - expect(result.stderr).toContain('ExperimentalWarning') - expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ - status: 'ready', - executable: nodePath - }) - }) - - it('publishes the reference atomically through a staged file under quoted paths', async () => { - const root = await directory() - const stage = await reserveStage(root) - const executable = join(root, 'runtimes', `node-${runtimeSha}`, 'bin', 'node') - const reference = join(root, 'opencode-sqlite-runtime.json') - await writeFile(reference, '{"old":true}') - const stagedReference = join(stage.slotDir, 'payload', 'ref.json') - await writeFile(stagedReference, JSON.stringify({ protocol: 1, executable })) - const published = await command( - publishOpenCodeRuntimeReferenceCommand({ - host, - nodePath, - stagedReference, - reference, - token: 'one' - }) - ) - expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published') - expect(JSON.parse(await readFile(reference, 'utf8'))).toEqual({ protocol: 1, executable }) - await expect(stat(join(root, `.runtime-ref-node-${runtimeSha}`))).rejects.toMatchObject({ - code: 'ENOENT' - }) - await command(cleanupOwnedRelayUploadStageCommand(host, stage, markerName)) - await expect(stat(stage.slotDir)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('writes the store ref that holds a pinned runtime before the reference names it', async () => { - const root = await directory() - const stagedReference = join(root, 'staged.json') - await writeFile(stagedReference, '{"protocol":1}') - const ref = join(root, `.runtime-ref-node-${runtimeSha}`) - const published = await command( - publishOpenCodeRuntimeReferenceCommand({ - host, - nodePath, - stagedReference, - reference: join(root, 'opencode-sqlite-runtime.json'), - token: 'two', - runtimeRef: { path: ref, sha256: runtimeSha } - }) - ) - expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published') - expect(await readFile(ref, 'utf8')).toBe(`${runtimeSha}\n`) - }) - - it('defers an empty host, honors database overrides, and ignores in-memory databases', async () => { - const home = await directory() - const probe = probeOpenCodeNodeSqliteCommand(host, nodePath, home) - expect(parseOpenCodeRuntimeResult((await command(probe)).stdout).status).toBe('not-needed') - const xdg = join(home, 'other data') - await mkdir(join(xdg, 'opencode'), { recursive: true }) - await writeFile(join(xdg, 'opencode', 'opencode-team.db'), '') - const environment = { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' } - expect(parseOpenCodeRuntimeResult((await command(probe, environment)).stdout).status).toBe( - 'ready' - ) - expect( - parseOpenCodeRuntimeResult( - (await command(probe, { ...environment, OPENCODE_DB: ':memory:' })).stdout - ).status - ).toBe('not-needed') - }) - - it('reclaims an abandoned binary through the shared pool while preserving fresh uploads', async () => { - const root = await directory() - const abandoned = await reserveStage(root) - await writeFile(join(abandoned.slotDir, 'payload', 'bun'), 'partial upload') - const fresh = await reserveStage(root) - await writeFile(join(fresh.slotDir, 'payload', 'bun'), 'active upload') - const old = new Date(Date.now() - 3_600_000) - await utimes(join(abandoned.slotDir, '.orca-upload-owner'), old, old) - const recovered = await command(recoverOneStaleRelayUploadStageCommand(host, abandoned.poolDir)) - expect(recovered.code, recovered.stderr).toBe(0) - await expect(stat(abandoned.slotDir)).rejects.toMatchObject({ code: 'ENOENT' }) - expect(await readFile(join(fresh.slotDir, 'payload', 'bun'), 'utf8')).toBe('active upload') - }) -}) - -it('carries Windows JavaScript and path arguments through the established PowerShell encoder', () => { - const windows = getRemoteHostPlatform('win32-x64') - const command = publishOpenCodeRuntimeReferenceCommand({ - host: windows, - nodePath: "C:/Program Files/O'Brien/node.exe", - stagedReference: 'C:/Users/a & b/.upload/ref.json', - reference: 'C:/Users/a & b/.orca-remote/relay-x/opencode-sqlite-runtime.json', - token: 'one', - runtimeRef: { - path: `C:/Users/a & b/.orca-remote/relay-x/.runtime-ref-node-${runtimeSha}`, - sha256: runtimeSha - } - }) - const decoded = decodeRemotePowerShellScript(command) - expect(decoded).toContain("& 'C:/Program Files/O''Brien/node.exe'") - expect(decoded).toContain('C:/Users/a & b/.upload/ref.json') - expect(decoded).toContain(`.runtime-ref-node-${runtimeSha}`) - expect(command).not.toContain('-ExecutionPolicy') - expect(decoded).not.toContain('Add-Type') -}) - -it('rejects missing or malformed host confirmations', () => { - expect(() => parseOpenCodeRuntimeResult('login banner')).toThrow('did not confirm') - expect(() => - parseOpenCodeRuntimeResult('ORCA_VAULT_SQLITE:{"status":"ready","executable":"node"}') - ).toThrow('invalid executable') -}) - -it('accepts an absolute Windows UNC executable path', () => { - const executable = String.raw`\\server\profile\vault-sqlite\bun.exe` - expect( - parseOpenCodeRuntimeResult( - `ORCA_VAULT_SQLITE:${JSON.stringify({ status: 'ready', executable })}` - ) - ).toEqual({ status: 'ready', executable }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts deleted file mode 100644 index 419e90d127d7..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NODE_SQLITE_READER_API_SOURCE } from '../sqlite/node-sqlite-reader-api' -import { shellEscape } from './ssh-connection-utils' -import { posix, win32 } from 'node:path' -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' - -export const OPENCODE_RUNTIME_RESULT = 'ORCA_VAULT_SQLITE:' - -function nodeCommand( - host: RemoteHostPlatform, - nodePath: string, - script: string, - args: string[] -): string { - if (isWindowsRemoteHost(host)) { - return powerShellCommand( - `& ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(script)} -- ${args.map(powerShellNativeArg).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` - ) - } - return `${shellEscape(nodePath)} -e ${shellEscape(script)} -- ${args.map(shellEscape).join(' ')}` -} - -const SEND = `const send=(value)=>console.log(${JSON.stringify(OPENCODE_RUNTIME_RESULT)}+JSON.stringify(value));` - -/** Host Node needs backup() too: 22.13-22.15 have DatabaseSync without it (design D4). */ -export function probeOpenCodeNodeSqliteCommand( - host: RemoteHostPlatform, - nodePath: string, - homeDirectory: string -): string { - return nodeCommand( - host, - nodePath, - `${SEND} -const fs=require('node:fs/promises');const path=require('node:path'); -(async()=>{const data=path.join(process.env.XDG_DATA_HOME?.trim()||path.join(process.argv[1],'.local','share'),'opencode'); -const override=process.env.OPENCODE_DB?.trim();let present=false; -try{if(override&&override!==':memory:'){present=(await fs.stat(path.isAbsolute(override)?override:path.join(data,override))).isFile()} -else if(!override){const directory=await fs.opendir(data);for await(const entry of directory){if(entry.isFile()&&/^opencode(?:-[A-Za-z0-9_.-]+)?\\.db$/.test(entry.name)){present=true;break}}}} -catch(error){if(error.code!=='ENOENT'&&error.code!=='ENOTDIR')throw error} -if(!present){send({status:'not-needed'});return} -let db;try{const sqlite=require('node:sqlite');if(!(${NODE_SQLITE_READER_API_SOURCE})(sqlite))throw Error('SQLite reader API missing'); -db=new sqlite.DatabaseSync(':memory:'); -if(db.prepare('SELECT 1 AS ready').get().ready!==1)throw Error('SQLite read failed'); -send({status:'ready',executable:process.execPath})}catch{send({status:'unsupported'})}finally{if(db)db.close()} -})().catch(error=>{console.error(error.message);process.exitCode=1})`, - [homeDirectory] - ) -} - -/** - * Publishes the reference. With `runtimeRef`, the relay dir first gains the store ref file that - * keeps the pinned runtime from store GC, so the reference never names an unheld runtime. - */ -export function publishOpenCodeRuntimeReferenceCommand(args: { - host: RemoteHostPlatform - nodePath: string - stagedReference: string - reference: string - token: string - runtimeRef?: { path: string; sha256: string } -}): string { - return nodeCommand( - args.host, - args.nodePath, - `${SEND} -const fs=require('node:fs/promises');const path=require('node:path'); -(async()=>{const [source,destination,token,refPath,refSha]=process.argv.slice(1);const temporary=destination+'.upload-'+token; -try{if(refPath)await fs.writeFile(refPath,refSha+'\\n'); -await fs.copyFile(source,temporary,require('node:fs').constants.COPYFILE_EXCL); -await fs.rename(temporary,destination);send({status:'published'})} -finally{await fs.rm(temporary,{force:true})}})().catch(error=>{console.error(error.message);process.exitCode=1})`, - [ - args.stagedReference, - args.reference, - args.token, - ...(args.runtimeRef ? [args.runtimeRef.path, args.runtimeRef.sha256] : []) - ] - ) -} - -export function parseOpenCodeRuntimeResult(output: string): { - status: string - executable?: string -} { - const line = output.split(/\r?\n/).findLast((entry) => entry.startsWith(OPENCODE_RUNTIME_RESULT)) - if (!line) { - throw new Error('The host did not confirm SQLite runtime setup.') - } - const result: unknown = JSON.parse(line.slice(OPENCODE_RUNTIME_RESULT.length)) - if ( - typeof result !== 'object' || - result === null || - !('status' in result) || - typeof result.status !== 'string' - ) { - throw new Error('Invalid SQLite runtime setup result.') - } - if ('executable' in result) { - if ( - typeof result.executable !== 'string' || - !(posix.isAbsolute(result.executable) || win32.isAbsolute(result.executable)) || - /[\0\r\n]/.test(result.executable) - ) { - throw new Error('SQLite runtime setup returned an invalid executable path.') - } - return { status: result.status, executable: result.executable } - } - return { status: result.status } -} diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts deleted file mode 100644 index be8fde1fc5e6..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createRemoteOpenCodeRuntimeRetry } from './ssh-relay-opencode-runtime-retry' - -afterEach(() => vi.useRealTimers()) - -describe('scan-triggered SSH OpenCode runtime preparation', () => { - it('rechecks an empty host after the cooldown, coalesces scans, and stops once ready', async () => { - vi.useFakeTimers() - const retry = vi.fn().mockResolvedValue('ready') - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('not-needed'), - Promise.resolve(true), - retry - ) - const signal = new AbortController().signal - await prepare(signal) - expect(retry).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(60_000) - await Promise.all([prepare(signal), prepare(signal), prepare(signal)]) - expect(retry).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(60_000) - await prepare(signal) - expect(retry).toHaveBeenCalledOnce() - }) - - it('waits for initial background cleanup before a scan can retry', async () => { - vi.useFakeTimers() - let finish!: () => void - const cleanup = new Promise((resolve) => { - finish = () => resolve(true) - }) - const retry = vi.fn().mockResolvedValue('ready') - const prepare = createRemoteOpenCodeRuntimeRetry(Promise.resolve('failed'), cleanup, retry) - await prepare(new AbortController().signal) - await vi.advanceTimersByTimeAsync(30_000) - const pending = prepare(new AbortController().signal) - await Promise.resolve() - expect(retry).not.toHaveBeenCalled() - finish() - await pending - expect(retry).toHaveBeenCalledOnce() - }) - - it('cancels a queued retry on session teardown without starting a remote command', async () => { - vi.useFakeTimers() - const retry = vi.fn() - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('not-needed'), - new Promise(() => {}), - retry - ) - const controller = new AbortController() - await prepare(controller.signal) - await vi.advanceTimersByTimeAsync(60_000) - const pending = prepare(controller.signal) - controller.abort() - await pending - await prepare(controller.signal) - expect(retry).not.toHaveBeenCalled() - }) - - it('never retries an unconfirmed remote teardown', async () => { - vi.useFakeTimers() - const retry = vi.fn() - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('teardown-unconfirmed'), - Promise.resolve(true), - retry - ) - await vi.advanceTimersByTimeAsync(3600_000) - await prepare(new AbortController().signal) - expect(retry).not.toHaveBeenCalled() - }) - - it('never retries after deployment cleanup reports an unconfirmed command', async () => { - vi.useFakeTimers() - const retry = vi.fn() - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('not-needed'), - Promise.resolve(false), - retry - ) - await prepare(new AbortController().signal) - await vi.advanceTimersByTimeAsync(60_000) - await prepare(new AbortController().signal) - await vi.advanceTimersByTimeAsync(3600_000) - await prepare(new AbortController().signal) - expect(retry).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.ts deleted file mode 100644 index 103da40a6cda..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-retry.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { RemoteOpenCodeRuntimeOutcome } from './ssh-relay-opencode-runtime' - -export type RemoteOpenCodeRuntimePreparation = (signal: AbortSignal) => Promise - -export function createRemoteOpenCodeRuntimeRetry( - initialSetup: Promise, - backgroundCleanup: Promise, - retry: (signal: AbortSignal) => Promise -): RemoteOpenCodeRuntimePreparation { - let nextAttempt = Infinity - let pending: Promise | undefined - const remember = (outcome: RemoteOpenCodeRuntimeOutcome): void => { - nextAttempt = - outcome === 'ready' || outcome === 'teardown-unconfirmed' - ? Infinity - : Date.now() + (outcome === 'not-needed' ? 60_000 : 30_000) - } - const initialized = initialSetup.then(remember).catch(() => {}) - return (signal) => { - if (signal.aborted) { - return Promise.resolve() - } - pending ??= (async () => { - await waitForPromiseWithSignal(initialized, signal) - if (Date.now() < nextAttempt) { - return - } - if (!(await waitForPromiseWithSignal(backgroundCleanup, signal))) { - nextAttempt = Infinity - return - } - signal.throwIfAborted() - remember(await retry(signal)) - })() - .catch(() => {}) - .finally(() => { - pending = undefined - }) - return pending - } -} diff --git a/src/main/ssh/ssh-relay-opencode-runtime.test.ts b/src/main/ssh/ssh-relay-opencode-runtime.test.ts deleted file mode 100644 index e96c15e2b2a4..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime.test.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - exec: vi.fn(), - upload: vi.fn(), - write: vi.fn(), - materialize: vi.fn(), - target: vi.fn(), - warm: false, - checksumError: false, - cleanupError: false, - reservationError: false -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: mocks.exec, - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - 'sshChannelCloseConfirmed' in error && - error.sshChannelCloseConfirmed === false -})) -vi.mock('./ssh-relay-install-transfers', () => ({ - uploadRelayDirectory: mocks.upload, - writeRelayFile: mocks.write -})) -vi.mock('./pinned-runtime-materializer', () => ({ - materializeNodeRuntimeArchive: mocks.materialize, - materializeCachedNodeRuntime: vi.fn() -})) -vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTargetFacts: mocks.target })) - -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -const host = getRemoteHostPlatform('linux-x64') -const remoteHome = '/home/ada' -const relayDir = `${remoteHome}/.orca-remote/relay-build` -const binary = `${remoteHome}/.orca-remote/runtimes/node-${NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256}/bin/node` -const archiveName = 'node-v24.21.0-linux-x64.tar.gz' -let cacheRoot: string -let runtime: string -const frame = (status: string, executable?: string) => - `${OPENCODE_RUNTIME_RESULT}${JSON.stringify({ status, executable })}\n` -const options = () => ({ nodePath: '/usr/bin/node', relayDir, cacheRoot }) - -function hostCommandResult(command: string): string { - if (command.includes('staging quota is full')) { - if (mocks.reservationError) { - throw new Error('staging quota is full') - } - return `__ORCA_UPLOAD_STAGE_SLOT__${command.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0` - } - if (command.includes("/runtimes/.store-lock' 2>/dev/null")) { - return 'OK' - } - if (command.includes('SELECT 1 AS ready')) { - return frame('unsupported') - } - if (command.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) { - return mocks.checksumError ? 'ORCA_NODE_RUNTIME_HASH_MISMATCH' : 'ORCA_NODE_RUNTIME_READY' - } - if (command.includes('ORCA_NODE_RUNTIME_MISSING')) { - return mocks.warm ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' - } - if (command.includes('published')) { - return frame('published') - } - if (mocks.cleanupError && command.includes('claim_identity') && !command.includes('old=')) { - throw Object.assign(new Error('Cleanup teardown is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - } - return '' -} - -function connection(system = false): SshConnection { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Setup reads only the mocked transport flag and connection generation; remote I/O is mocked. - return { - usesSystemSshTransport: () => system, - getConnectGeneration: () => 1 - } as unknown as SshConnection -} - -beforeEach(async () => { - vi.resetAllMocks() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - cacheRoot = await mkdtemp(join(tmpdir(), 'orca-vault-runtime-')) - runtime = join(cacheRoot, archiveName) - await writeFile(runtime, 'verified runtime') - mocks.materialize.mockResolvedValue(runtime) - mocks.target.mockResolvedValue({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } }) - mocks.warm = false - mocks.checksumError = false - mocks.cleanupError = false - mocks.reservationError = false - mocks.exec.mockImplementation(async (_conn, command: string) => hostCommandResult(command)) -}) - -afterEach(async () => { - vi.useRealTimers() - vi.restoreAllMocks() - await rm(cacheRoot, { recursive: true, force: true }) -}) - -describe('SSH OpenCode runtime setup', () => { - it('publishes a capable existing Node without materializing or uploading a runtime', async () => { - mocks.exec.mockResolvedValueOnce(frame('ready', '/opt/node 24/bin/node')) - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'ready' - ) - expect(mocks.target).not.toHaveBeenCalled() - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ - protocol: 1, - executable: '/opt/node 24/bin/node' - }) - }) - - it('installs the pinned Node archive into the shared runtimes/ store after the read probe fails', async () => { - mocks.upload.mockImplementation(async (_conn, localDir: string, remoteDir: string) => { - expect(remoteDir).toMatch( - /\/\.orca-remote\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ - ) - expect(await readdir(localDir)).toEqual([archiveName]) - expect(await readFile(join(localDir, archiveName), 'utf8')).toBe('verified runtime') - }) - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'ready' - ) - expect(mocks.target).toHaveBeenCalledWith( - expect.objectContaining({ host, signal: expect.any(AbortSignal) }) - ) - expect(mocks.materialize).toHaveBeenCalledWith('linux-x64-glibc', cacheRoot, { - signal: expect.any(AbortSignal) - }) - expect(await readdir(cacheRoot)).toEqual([archiveName]) - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary }) - const commands: string[] = mocks.exec.mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('vault-sqlite'))).toBe(false) - }) - - it('reuses a runtime the host store already verified without downloading it again', async () => { - mocks.warm = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'ready' - ) - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary }) - }) - - it('writes the reference atomically through the reserved staging namespace', async () => { - await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) - const writeOptions = mocks.write.mock.calls[0][4] - expect(writeOptions.sftpNamespace.homeRelativeNamespaceRoot).toMatch( - /^\.orca-remote\/\.upload-stages\/slot-0$/ - ) - expect(writeOptions.sftpNamespace.homeRelativePath).toMatch(/\/opencode-sqlite-runtime\.json$/) - }) - - it('skips namespace probing on system SSH', async () => { - await ensureRemoteOpenCodeRuntime(connection(true), host, remoteHome, options()) - expect(mocks.write.mock.calls[0][4].sftpNamespace).toBeUndefined() - }) - - it('coalesces repeated setup for one execution connection and directory', async () => { - const conn = connection() - const result = await Promise.all([ - ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()), - ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - ]) - expect(result).toEqual(['ready', 'ready']) - expect(mocks.upload).toHaveBeenCalledOnce() - }) - - it('coalesces the bounded target cache fill across hosts', async () => { - let finish!: (path: string) => void - mocks.materialize.mockReturnValue( - new Promise((resolve) => { - finish = resolve - }) - ) - const first = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) - const second = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) - await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce()) - finish(runtime) - expect(await Promise.all([first, second])).toEqual(['ready', 'ready']) - expect(mocks.upload).toHaveBeenCalledTimes(2) - }) - - it('never publishes a reference after a failed remote runtime checksum', async () => { - mocks.checksumError = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'failed' - ) - expect(mocks.write).not.toHaveBeenCalled() - }) - - it('aborts an upload without publishing or running further host commands', async () => { - const controller = new AbortController() - mocks.upload.mockImplementation(async (_conn, _local, _remote, _host, transfer) => { - controller.abort() - transfer.signal.throwIfAborted() - }) - expect( - await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, { - ...options(), - signal: controller.signal - }) - ).toBe('teardown-unconfirmed') - expect(mocks.write).not.toHaveBeenCalled() - expect(mocks.exec).toHaveBeenCalledTimes(3) - }) - - it('bounds even an unresponsive setup operation at 180 seconds', async () => { - vi.useFakeTimers() - mocks.exec.mockReturnValue(new Promise(() => {})) - const conn = connection() - const result = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - await vi.advanceTimersByTimeAsync(180_000) - expect(await result).toBe('teardown-unconfirmed') - expect(mocks.exec.mock.calls[0][2].signal.aborted).toBe(true) - expect(mocks.materialize).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledOnce() - }) - - it('admits setup on a new connection generation after an unconfirmed teardown', async () => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - mocks.exec.mockRejectedValueOnce( - Object.assign(new Error('Channel teardown is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledOnce() - generation.mockReturnValue(2) - mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node')) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') - expect(mocks.write).toHaveBeenCalledOnce() - }) - - it.each(['ready', 'not-needed'] as const)( - 'refuses a late %s result from a superseded setup', - async (status) => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - mocks.exec.mockImplementationOnce(async () => { - generation.mockReturnValue(2) - return frame(status, '/usr/bin/node') - }) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('failed') - expect(mocks.exec).toHaveBeenCalledOnce() - expect(mocks.write).not.toHaveBeenCalled() - } - ) - - it.each(['publication', 'cleanup'] as const)( - 'refuses completed setup when its generation changes during %s', - async (stage) => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - const started = Promise.withResolvers() - const finish = Promise.withResolvers() - mocks.exec.mockImplementation(async (_conn, command: string) => { - if (command.includes('SELECT 1 AS ready')) { - return frame('ready', '/usr/bin/node') - } - const selected = - stage === 'publication' - ? command.includes('published') - : command.includes('claim_identity') && !command.includes('old=') - if (selected) { - started.resolve() - return finish.promise - } - return hostCommandResult(command) - }) - const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - await started.promise - generation.mockReturnValue(2) - finish.resolve(stage === 'publication' ? frame('published') : '') - - expect(await pending).toBe('failed') - expect(mocks.exec).toHaveBeenCalledTimes(stage === 'publication' ? 4 : 5) - expect(mocks.write).toHaveBeenCalledOnce() - } - ) - - it('keeps a newer setup registered when the superseded setup finishes late', async () => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - const oldProbe = Promise.withResolvers() - const currentProbe = Promise.withResolvers() - mocks.exec.mockReturnValueOnce(oldProbe.promise).mockReturnValueOnce(currentProbe.promise) - const oldSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - generation.mockReturnValue(2) - const currentSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - oldProbe.resolve(frame('not-needed')) - expect(await oldSetup).toBe('failed') - - const joined = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - expect(mocks.exec).toHaveBeenCalledTimes(2) - currentProbe.resolve(frame('not-needed')) - expect(await Promise.all([currentSetup, joined])).toEqual(['not-needed', 'not-needed']) - }) - - it.each(['deadline', 'caller'] as const)( - 'allows retry after local download cancellation by %s without reserving a stage', - async (cause) => { - const controller = new AbortController() - let finish!: (path: string) => void - mocks.materialize.mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve - }) - ) - if (cause === 'deadline') { - vi.useFakeTimers() - } - const conn = connection() - const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, { - ...options(), - signal: controller.signal - }) - await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce()) - expect(mocks.exec).toHaveBeenCalledTimes(2) - if (cause === 'deadline') { - await vi.advanceTimersByTimeAsync(180_000) - } else { - controller.abort() - } - expect(await pending).toBe('failed') - finish(runtime) - vi.useRealTimers() - await new Promise((resolve) => setImmediate(resolve)) - expect(mocks.exec).toHaveBeenCalledTimes(2) - expect(mocks.upload).not.toHaveBeenCalled() - expect(mocks.write).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') - expect(mocks.upload).toHaveBeenCalledOnce() - } - ) - - it('retains the upload stage when a failed transfer may still be running', async () => { - mocks.upload.mockRejectedValue( - Object.assign(new Error('Upload teardown is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ) - const conn = connection() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledTimes(3) - expect(mocks.exec.mock.calls.some(([, command]) => command.startsWith('rm -rf'))).toBe(false) - expect(mocks.write).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledTimes(3) - }) - - it('reports an unconfirmed stage cleanup to the deployment command queue', async () => { - mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node')) - mocks.cleanupError = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledTimes(5) - }) - - it('skips installation without data and retries when a database appears on the same connection', async () => { - const conn = connection() - mocks.exec.mockResolvedValueOnce(frame('not-needed')) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('not-needed') - expect(mocks.exec).toHaveBeenCalledOnce() - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') - expect(mocks.upload).toHaveBeenCalledOnce() - }) - - it('fails optionally without publishing when all bounded stages are occupied', async () => { - mocks.reservationError = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'failed' - ) - // Includes the store-lock round trips (mkdir, acquire, re-probe+promote, release) around promotion. - expect(mocks.exec).toHaveBeenCalledTimes(10) - expect(mocks.write).not.toHaveBeenCalled() - }) - - it('does not mistake an unanswered Node probe for an old runtime', async () => { - mocks.exec.mockResolvedValue('login banner only') - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'failed' - ) - expect(mocks.materialize).not.toHaveBeenCalled() - }) -}) - -describe('SSH OpenCode runtime setup on a Windows host', () => { - const windows = getRemoteHostPlatform('win32-x64') - const home = 'C:/Users/ada' - const windowsRelayDir = `${home}/.orca-remote/relay-build` - const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 - const storeNode = `${home}/.orca-remote/runtimes/node-${sha}/node.exe` - - function answerWindows(storeReady: boolean): string[] { - const scripts: string[] = [] - mocks.target.mockResolvedValue({ target: 'win32-x64', glibc: null }) - mocks.exec.mockImplementation(async (_conn, command: string) => { - const script = decodeRemotePowerShellScript(command) - scripts.push(script) - if (script.includes('SELECT 1 AS ready')) { - return frame('unsupported') - } - if (script.includes('.store-lock') && script.includes('CreateNew')) { - return 'OK' - } - if (script.includes('Invoke-OrcaPromote')) { - return 'ORCA_NODE_RUNTIME_READY' - } - if (script.includes('ORCA_NODE_RUNTIME_MISSING')) { - return storeReady ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' - } - if (script.includes('staging quota is full')) { - return `__ORCA_UPLOAD_STAGE_SLOT__${script.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0` - } - if (script.includes('COPYFILE_EXCL')) { - return frame('published') - } - return '' - }) - return scripts - } - - it('installs the official archive through the runtime store, not a client-extracted node.exe', async () => { - const scripts = answerWindows(false) - expect( - await ensureRemoteOpenCodeRuntime(connection(true), windows, home, { - nodePath: 'C:/Program Files/nodejs/node.exe', - relayDir: windowsRelayDir, - cacheRoot - }) - ).toBe('ready') - expect(mocks.materialize).toHaveBeenCalledWith('win32-x64', cacheRoot, expect.any(Object)) - expect(mocks.upload).toHaveBeenCalledOnce() - expect(mocks.upload.mock.calls[0][2]).toMatch( - /\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ - ) - expect(scripts.some((script) => script.includes('Invoke-OrcaPromote'))).toBe(true) - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: storeNode }) - const publish = scripts.find((script) => script.includes('COPYFILE_EXCL')) - expect(publish).toContain(`${windowsRelayDir}/.runtime-ref-node-${sha}`) - // Stage fencing reads file IDs through the verified node.exe, so no script compiles C#. - const stageScripts = scripts.filter((script) => script.includes('$getFileIdentity')) - expect(stageScripts.length).toBeGreaterThan(0) - for (const script of stageScripts) { - expect(script).toContain(`$orcaIdentityNode = '${storeNode}'`) - expect(script).not.toContain('Add-Type') - } - }) - - it("reuses a verified store runtime and prefers the relay's own verified node.exe", async () => { - const scripts = answerWindows(true) - const relayNode = `${home}/.orca-remote/runtimes/node-other/node.exe` - expect( - await ensureRemoteOpenCodeRuntime(connection(true), windows, home, { - nodePath: relayNode, - verifiedNodePath: relayNode, - relayDir: windowsRelayDir, - cacheRoot - }) - ).toBe('ready') - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - for (const script of scripts.filter((s) => s.includes('$getFileIdentity'))) { - expect(script).toContain(`$orcaIdentityNode = '${relayNode}'`) - } - }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime.ts b/src/main/ssh/ssh-relay-opencode-runtime.ts deleted file mode 100644 index f51343cc683b..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { randomBytes } from 'node:crypto' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { SshConnection } from './ssh-connection' -import type { RemoteRuntimeStep } from './orcad-remote-node-runtime' -import { preparePinnedNodeForVault } from './ssh-relay-opencode-pinned-node' -import { RUNTIME_REF_NODE_PREFIX } from './remote-node-runtime-store-inventory' -import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { writeRelayFile } from './ssh-relay-install-transfers' -import { - createRelayUploadStageNamespace, - relayUploadStageSftpNamespaceMapping -} from './ssh-relay-install-namespace' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - recoverOneStaleRelayUploadStageCommand, - reserveRelayUploadStageCommand, - RELAY_UPLOAD_STAGE_POOL_NAME -} from './ssh-relay-upload-stage-commands' -import { - parseOpenCodeRuntimeResult, - probeOpenCodeNodeSqliteCommand, - publishOpenCodeRuntimeReferenceCommand -} from './ssh-relay-opencode-runtime-commands' - -const SETUP_TIMEOUT_MS = 180_000 -const RUNTIME_REFERENCE_NAME = 'opencode-sqlite-runtime.json' -export type RemoteOpenCodeRuntimeOutcome = - | 'ready' - | 'not-needed' - | 'failed' - | 'teardown-unconfirmed' -const installations = new WeakMap< - SshConnection, - { generation: number; byDirectory: Map> } ->() - -type SetupOptions = { - nodePath: string - /** The relay's verified pinned node.exe, if any; stage fencing then needs no Add-Type (D5). */ - verifiedNodePath?: string - relayDir: string - signal?: AbortSignal - cacheRoot?: string -} - -/** Optional companion setup; the host's relay and terminals never depend on it. */ -export function ensureRemoteOpenCodeRuntime( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: SetupOptions -): Promise { - const generation = conn.getConnectGeneration() - let current = installations.get(conn) - if (current?.generation !== generation) { - current = { generation, byDirectory: new Map() } - installations.set(conn, current) - } - const { byDirectory } = current - const active = byDirectory.get(options.relayDir) - if (active) { - return waitForPromiseWithSignal(active, options.signal).catch(() => 'teardown-unconfirmed') - } - const timeout = new AbortController() - const timer = setTimeout( - () => timeout.abort(new Error('SSH SQLite runtime setup timed out.')), - SETUP_TIMEOUT_MS - ) - timer.unref() - const signal = options.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal - let remotePending = false - let remoteUnconfirmed = false - const assertCurrentGeneration = (): void => { - if (conn.getConnectGeneration() !== generation) { - throw new Error('SSH connection changed during SQLite runtime setup.') - } - } - const remote: RemoteRuntimeStep = async (operation) => { - signal.throwIfAborted() - assertCurrentGeneration() - remotePending = true - try { - const result = await operation() - assertCurrentGeneration() - return result - } catch (error) { - remoteUnconfirmed ||= signal.aborted || isUnconfirmedSshCommandTermination(error) - throw error - } finally { - remotePending = false - } - } - const pending = waitForPromiseWithSignal( - install(conn, host, remoteHome, options, signal, remote), - signal - ) - .then((outcome) => { - assertCurrentGeneration() - return outcome - }) - .catch((error: unknown) => { - console.warn( - '[ssh-relay] OpenCode history runtime setup did not finish:', - error instanceof Error ? error.message : String(error) - ) - return remotePending || remoteUnconfirmed || isUnconfirmedSshCommandTermination(error) - ? ('teardown-unconfirmed' as const) - : ('failed' as const) - }) - .then((outcome) => { - clearTimeout(timer) - // An unresolved channel must not admit another installer on this connection. - if (outcome !== 'teardown-unconfirmed') { - byDirectory.delete(options.relayDir) - } - return outcome - }) - byDirectory.set(options.relayDir, pending) - return pending -} - -async function install( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: SetupOptions, - signal: AbortSignal, - remote: RemoteRuntimeStep -): Promise { - const exec = async (command: string): Promise => { - signal.throwIfAborted() - const output = await remote(() => - execCommand(conn, command, { - signal, - wrapCommand: !isWindowsRemoteHost(host) - }) - ) - signal.throwIfAborted() - return output - } - const node = parseOpenCodeRuntimeResult( - await exec(probeOpenCodeNodeSqliteCommand(host, options.nodePath, remoteHome)) - ) - if (node.status === 'not-needed') { - return 'not-needed' - } - if (node.status !== 'ready' && node.status !== 'unsupported') { - throw new Error('The host did not complete its SQLite read probe.') - } - let executable = node.executable - let runtimeRef: { path: string; sha256: string } | undefined - let identityNode = options.verifiedNodePath - if (node.status === 'unsupported') { - const pinned = await preparePinnedNodeForVault({ - conn, - host, - relayDir: options.relayDir, - cacheRoot: options.cacheRoot, - signal, - exec, - remote - }) - executable = pinned.executable - identityNode ??= pinned.executable - runtimeRef = { - path: joinRemotePath( - host, - options.relayDir, - `${RUNTIME_REF_NODE_PREFIX}${pinned.runtimeSha256}` - ), - sha256: pinned.runtimeSha256 - } - } - if (!executable) { - throw new Error('The host did not identify its SQLite executable.') - } - const token = randomBytes(12).toString('hex') - const relativePool = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` - const poolDir = joinRemotePath(host, remoteHome, relativePool) - const owner = createRelayInstallMarkerFileName() - const identity = identityNode ? { node: identityNode } : undefined - await exec(recoverOneStaleRelayUploadStageCommand(host, poolDir, undefined, identity)) - const stage = parseReservedRelayUploadStage( - host, - poolDir, - owner, - await exec(reserveRelayUploadStageCommand(host, poolDir, owner, identity)) - ) - const stageDir = stage.slotDir - const namespace = createRelayUploadStageNamespace(`${relativePool}/${stage.slotName}`, owner) - const mapping = (file?: string) => - !isWindowsRemoteHost(host) && conn.usesSystemSshTransport?.() !== true - ? relayUploadStageSftpNamespaceMapping(namespace, host, stageDir, file) - : undefined - let cleanupAllowed = true - try { - const referenceName = RUNTIME_REFERENCE_NAME - const stagedReference = joinRemotePath(host, stageDir, 'payload', referenceName) - signal.throwIfAborted() - await remote(() => - writeRelayFile(conn, host, stagedReference, JSON.stringify({ protocol: 1, executable }), { - signal, - sftpNamespace: mapping(referenceName) - }) - ) - const published = parseOpenCodeRuntimeResult( - await exec( - publishOpenCodeRuntimeReferenceCommand({ - host, - nodePath: options.nodePath, - stagedReference, - reference: joinRemotePath(host, options.relayDir, referenceName), - token, - runtimeRef - }) - ) - ) - return published.status === 'published' ? 'ready' : 'failed' - } catch (error) { - cleanupAllowed = !isUnconfirmedSshCommandTermination(error) - throw error - } finally { - if (cleanupAllowed && !signal.aborted) { - await exec(cleanupOwnedRelayUploadStageCommand(host, stage, owner, identity)).catch( - (error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - } - ) - } - } -} diff --git a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts deleted file mode 100644 index e1d685611642..000000000000 --- a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' -import { isProvenProcessExit } from '../../shared/terminal-exit-cause' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(async (_mux: unknown, options: { clientInstanceId: string }) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-previous-relay-terminals', () => import('./ssh-previous-relay-census-test-double')) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 17), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-remote-orca-cli', () => ({ - runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn() -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { getSshPtyProvider, getPtyIdsForConnection, clearProviderPtyState, deletePtyOwnership } = - await import('../ipc/pty') - -const APP_PTY_ID = 'ssh:target-1@@pty-live' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function detachedLease() { - return { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached' as const, - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: LEAF_ID - } -} - -/** - * STA-3376: every reattach failure walks away from a remote PTY that may still be running a user's - * work. Orca's rule is that unprovable liveness never authorizes destroying a session, so these - * assert the opposite of a leak test: the shell must survive, and the lease must stay in a state - * the user-facing terminate action can still reach. - */ -describe('SshRelaySession abandoned remote PTYs', () => { - beforeEach(() => { - vi.clearAllMocks() - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - async function establishWithFailingReattach(error: Error): Promise<{ - deps: ReturnType - shutdown: ReturnType - attachForReconnect: ReturnType - runtime: { onPtyExit: ReturnType; registerPty: ReturnType } - }> { - const deps = createMockDeps() - const shutdown = vi.fn().mockResolvedValue(undefined) - const attachForReconnect = vi.fn().mockRejectedValue(error) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect, - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof deps.mockStore.getSshRemotePtyLeases - >) - const runtime = { onPtyExit: vi.fn(), registerPty: vi.fn() } - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward, - runtime as never - ) - - await session.establish(deps.mockConn) - - return { deps, shutdown, attachForReconnect, runtime } - } - - it('leaves a live shell running and reachable when reattach attempts are exhausted', async () => { - // A transport stall proves nothing about the remote shell; the user's long-running process - // may be untouched, so the lease must stay terminable rather than be tombstoned as expired. - const { deps, shutdown, attachForReconnect, runtime } = await establishWithFailingReattach( - new Error('PTY reattach attempt timed out after 15000ms') - ) - - expect(attachForReconnect).toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - expect(deletePtyOwnership).not.toHaveBeenCalledWith(APP_PTY_ID) - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - }) - - it('leaves another pane live shell running when the relay reports an identity mismatch', async () => { - // The relay answered that a *live* PTY holds this id under a different pane identity. Killing - // it would destroy an unrelated terminal, so this path may only stop claiming the id. - const { deps, shutdown, attachForReconnect, runtime } = await establishWithFailingReattach( - new Error('PTY "pty-live" not found (identity mismatch)') - ) - - expect(attachForReconnect).toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - expect(clearProviderPtyState).not.toHaveBeenCalledWith(APP_PTY_ID) - }) - - it('publishes a disowned-source signal when the relay answers not-found', async () => { - // A reachable relay answered for this exact id and disowned it. That licenses replacing the - // pane — respawning leaks the old process rather than killing it — but a restarted relay - // answers the same way for ids it never minted, so this is not an `exited` verdict - // (docs/reference/ssh-execution-boundary.md). - const { deps, shutdown, runtime } = await establishWithFailingReattach( - new Error('PTY "pty-live" not found') - ) - - expect(shutdown).not.toHaveBeenCalled() - // The runtime is where a death certificate would land (`hostExitConfirmed`), and this branch - // holds no evidence to write one from. - expect(runtime.onPtyExit).not.toHaveBeenCalled() - // 'expired' records that reattach gave up on the id, not that the shell died; ssh:terminateSessions still reaches it. - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - expect(clearProviderPtyState).toHaveBeenCalledWith(APP_PTY_ID) - expect(deps.mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { - id: APP_PTY_ID, - code: -1, - ptySourceDisowned: true - }) - const exitCall = vi - .mocked(deps.mockWindow.webContents.send) - .mock.calls.find(([channel]) => channel === 'pty:exit') - if (!exitCall) { - throw new Error('expected a pty:exit publication') - } - // The ratchet: the verdict rides its own field, never the code. Swapping -1 for a provable - // status would make every reader that keys off the code close the tab and drop its leaf↔PTY - // binding, which is a far wider claim than "this id is gone from this relay". - expect(isProvenProcessExit((exitCall[1] as { code: number }).code)).toBe(false) - }) - - it('keeps a recovered session attached when reattach succeeds after an earlier drop', async () => { - const deps = createMockDeps() - const shutdown = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId: 'incarnation-1' }), - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof deps.mockStore.getSshRemotePtyLeases - >) - const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward, - runtime as never - ) - - await session.establish(deps.mockConn) - - expect(shutdown).not.toHaveBeenCalled() - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { - tabId: 'tab-1', - leafId: LEAF_ID, - incarnationId: 'incarnation-1' - }) - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-pinned-node-install.test.ts b/src/main/ssh/ssh-relay-pinned-node-install.test.ts deleted file mode 100644 index 2c8f5ccbbe25..000000000000 --- a/src/main/ssh/ssh-relay-pinned-node-install.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { - ensureRemoteOrcadNodeRuntime, - REMOTE_NODE_RUNTIME_MISSING, - REMOTE_NODE_RUNTIME_READY, - RemoteNodeRuntimeSecurityModifiedError, - RemoteNodeRuntimeSelfTestError -} from './orcad-remote-node-runtime' -import { - REMOTE_NODE_RUNTIME_EXIT_PREFIX, - REMOTE_NODE_RUNTIME_SELFTEST_FAILED -} from './orcad-remote-node-runtime-report' -import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' -import { - PinnedRelayFallbackError, - planPinnedNodeRelay, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { resetPinnedRuntimeRefusalsForTests } from './ssh-relay-pinned-refusal-cache' -import { runPinnedRuntimeSelfTest } from './ssh-relay-runtime-self-test' -import type { HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' -import { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' -import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) -vi.mock('./orcad-remote-node-runtime', async (importOriginal) => ({ - ...(await importOriginal>()), - ensureRemoteOrcadNodeRuntime: vi.fn() -})) -vi.mock('./remote-node-runtime-store-lock', () => ({ - withRuntimeStoreLock: vi.fn((_conn, _host, _store, task: () => Promise) => task()) -})) -vi.mock('./ssh-relay-runtime-self-test', async (importOriginal) => ({ - ...(await importOriginal>()), - runPinnedRuntimeSelfTest: vi.fn() -})) - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All connection operations are mocked. -const conn = {} as SshConnection -const host = getRemoteHostPlatform('linux-x64') -const plan: PinnedRelayPlan = { - kind: 'pinned-node', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: '0.1.0+feedfacecafe', - addons: { dir: '/tmp/a', digest: 'd', dispose: async () => {} }, - runtimeArchive: async () => '/tmp/archive.tar.gz' -} -const context = { - conn, - host, - remoteRelayDir: '/home/u/.orca-remote/relay-0.1.0+feedfacecafe', - plan, - targetId: 'target-1' -} - -beforeEach(() => { - vi.mocked(execCommand).mockReset() - vi.mocked(ensureRemoteOrcadNodeRuntime).mockReset().mockResolvedValue({ - executable: '/home/u/.orca-remote/runtimes/node-test/bin/node', - transfer: 'uploaded' - }) - vi.mocked(runPinnedRuntimeSelfTest).mockReset() - resetPinnedRuntimeRefusalsForTests() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) -}) - -describe('ensurePinnedRelayRuntime', () => { - it('trusts the verified marker on the warm path and never re-uploads', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_READY) - await ensurePinnedRelayRuntime(context, true) - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - }) - - it.each([ - [ - 'a library removed since the install', - 127, - 'libatomic.so.1: cannot open shared object file', - 'missing_lib' - ], - ['an exec policy that now denies it', 126, 'sh: node: Permission denied', 'noexec'] - ] as const)( - 'refuses a cached runtime that no longer runs (%s) before any launch', - async (_label, status, output, reason) => { - vi.mocked(execCommand).mockResolvedValueOnce( - `${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}\n${REMOTE_NODE_RUNTIME_EXIT_PREFIX}${status}\n${output}\n` - ) - const failure = await ensurePinnedRelayRuntime(context, true).catch((e: unknown) => e) - expect(failure).toMatchObject({ reason }) - expect(String(vi.mocked(execCommand).mock.calls[0]?.[1])).toContain('--version') - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - } - ) - - it('reinstalls a runtime that disappeared from under an installed relay', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) - await ensurePinnedRelayRuntime(context, true) - expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledWith( - expect.objectContaining({ slotDir: context.remoteRelayDir, target: 'linux-x64-glibc' }) - ) - }) - - it('turns a classified runtime refusal into a remembered fallback', async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError(126, 'sh: node: Permission denied') - ) - const failure = await ensurePinnedRelayRuntime(context, false).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(PinnedRelayFallbackError) - expect(failure).toMatchObject({ reason: 'noexec' }) - - vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.31') - await expect( - planPinnedNodeRelay({ conn, host, baseVersion: '0.1.0+abc', targetId: 'target-1' }) - ).rejects.toMatchObject({ reason: 'noexec', remembered: true }) - }) - - it("steps down when NixOS's stub loader refuses the generic Linux runtime", async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError( - 127, - 'Could not start dynamically linked executable: /home/u/.orca-remote/runtimes/node-x/bin/node\n' + - 'NixOS cannot run dynamically linked executables intended for generic\n' + - 'linux environments out of the box. For more information, see:\n' + - 'https://nix.dev/permalink/stub-ld' - ) - ) - const failure = await ensurePinnedRelayRuntime(context, false).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(PinnedRelayFallbackError) - expect(failure).toMatchObject({ reason: 'wrong_libc' }) - }) - - it('keeps an unclassified runtime failure as an error, not a step down', async () => { - const failure = new RemoteNodeRuntimeSelfTestError(1, 'something unexpected') - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce(failure) - await expect(ensurePinnedRelayRuntime(context, false)).rejects.toBe(failure) - }) -}) - -describe('verifyPinnedRelayInstall', () => { - beforeEach(() => { - vi.mocked(execCommand).mockResolvedValue(REMOTE_NODE_RUNTIME_READY) - }) - - it('confirms the runtime under the store lock once the relay ref is visible', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) - await verifyPinnedRelayInstall(context).catch(() => {}) - expect(withRuntimeStoreLock).toHaveBeenCalledWith( - conn, - host, - '/home/u/.orca-remote/runtimes', - expect.any(Function), - undefined - ) - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - }) - - it('reinstalls a runtime a store GC collected before the ref existed', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) - await verifyPinnedRelayInstall(context).catch(() => {}) - expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce() - }) - - it('passes a verified runtime through', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'passed', - report: { - ok: true, - nonce: 'n', - node: 'v24.21.0', - napi: '10', - glibcVersionRuntime: '2.31', - runtime: 'pinned-node' - } - }) - await expect(verifyPinnedRelayInstall(context)).resolves.toBeUndefined() - expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( - conn, - context.remoteRelayDir, - expect.stringMatching(/\/\.orca-remote\/runtimes\/node-[0-9a-f]{64}\/bin\/node$/), - undefined, - { host, expectPinnedVersion: true } - ) - }) - - it('self-tests rung C on the host Node without the pinned version check or a cached refusal', async () => { - vi.mocked(withRuntimeStoreLock).mockClear() - vi.mocked(execCommand).mockResolvedValue('') - const run = new RelayRuntimeLadderRun('target-1', null, true) - const hostPlan: HostNodeAddonRelayPlan = { - kind: 'host-node-addons', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: '0.1.0+0123456789ab', - addons: { dir: '/tmp/a', digest: 'd', dispose: async () => {} }, - nodePath: '/opt/node18/bin/node', - hostNode: { version: { major: 18, minor: 20 }, napi: 9 } - } - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'refused', - refusal: 'libc_floor', - detail: "GLIBC_2.33' not found" - }) - await expect( - verifyPinnedRelayInstall({ ...context, plan: hostPlan, run }) - ).rejects.toMatchObject({ reason: 'libc_floor' }) - expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( - conn, - context.remoteRelayDir, - '/opt/node18/bin/node', - undefined, - { host, expectPinnedVersion: false } - ) - expect(run.selfTest).toBe('refused') - // Rung C runs no managed runtime, so there is nothing to hold under the store lock. - expect(withRuntimeStoreLock).not.toHaveBeenCalled() - // A host Node refusal says nothing about Orca's pinned Node on this host. - vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.31') - await expect( - planPinnedNodeRelay({ - conn, - host, - baseVersion: '0.1.0+abcdef012345', - targetId: 'target-1', - materializeOrcad: () => Promise.reject(new Error('stop here')) - }) - ).rejects.toMatchObject({ reason: 'artifacts_unavailable', remembered: false }) - }) - - it('falls back on a refusal', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'refused', - refusal: 'libc_floor', - detail: "GLIBC_2.33' not found" - }) - await expect(verifyPinnedRelayInstall(context)).rejects.toMatchObject({ - reason: 'libc_floor' - }) - }) - - it('never falls back on an unverifiable self-test', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'unverifiable', - detail: 'timed out' - }) - const failure = await verifyPinnedRelayInstall(context).catch((error: unknown) => error) - expect(failure).not.toBeInstanceOf(PinnedRelayFallbackError) - expect(String(failure)).toContain('unverifiable') - }) - - it('rethrows an unconfirmed teardown so the install lock stays held', async () => { - const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'unverifiable', - detail: 'lost', - cause: lost - }) - await expect(verifyPinnedRelayInstall(context)).rejects.toBe(lost) - }) - - it('makes the macOS spawn-helper executable before the self-test', async () => { - vi.mocked(execCommand).mockResolvedValue('') - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) - await verifyPinnedRelayInstall({ - ...context, - host: getRemoteHostPlatform('darwin-arm64'), - plan: { ...plan, target: 'darwin-arm64' } - }).catch(() => {}) - expect(execCommand).toHaveBeenCalledWith( - conn, - expect.stringContaining("node_modules/node-pty/build/Release/spawn-helper'"), - expect.anything() - ) - }) -}) - -describe('pinned relay on a Windows host', () => { - const windowsHost = getRemoteHostPlatform('win32-x64') - const windowsContext = { - ...context, - host: windowsHost, - remoteRelayDir: 'C:/Users/u/.orca-remote/relay-0.1.0+feedfacecafe', - plan: { ...plan, target: 'win32-x64' as const, glibc: null } - } - - it('checks the warm runtime through one unwrapped powershell.exe', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(`${REMOTE_NODE_RUNTIME_READY}\r\n`) - await ensurePinnedRelayRuntime(windowsContext, true) - const [, command, options] = vi.mocked(execCommand).mock.calls[0] - expect(command).toMatch(/^powershell\.exe /) - expect(options).toMatchObject({ wrapCommand: false }) - expect(decodeRemotePowerShellScript(command)).toContain('/.orca-remote/runtimes/node-') - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - }) - - it('steps down to host Node, and remembers it, when security software touched node.exe', async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSecurityModifiedError('node.exe changed after it ran') - ) - const failure = await ensurePinnedRelayRuntime(windowsContext, false).catch( - (error: unknown) => error - ) - expect(failure).toBeInstanceOf(PinnedRelayFallbackError) - expect(failure).toMatchObject({ reason: 'security_software' }) - await expect( - planPinnedNodeRelay({ - conn, - host: windowsHost, - baseVersion: '0.1.0+abc', - targetId: 'target-1' - }) - ).rejects.toMatchObject({ reason: 'security_software', remembered: true }) - }) - - it('classifies application control blocking node.exe as a refusal', async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError(-1, 'This program is blocked by group policy.') - ) - await expect(ensurePinnedRelayRuntime(windowsContext, false)).rejects.toMatchObject({ - reason: 'noexec' - }) - }) - - it('confirms the runtime under the store lock, then self-tests on node.exe with no chmod step', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(`${REMOTE_NODE_RUNTIME_READY}\r\n`) - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'passed', - report: { - ok: true, - nonce: 'n', - node: 'v24.21.0', - napi: '10', - glibcVersionRuntime: null, - runtime: 'pinned-node' - } - }) - await expect(verifyPinnedRelayInstall(windowsContext)).resolves.toBeUndefined() - // Windows store GC collects too, so the held-runtime check runs there as well (design D5). - expect(withRuntimeStoreLock).toHaveBeenCalledWith( - conn, - windowsHost, - 'C:/Users/u/.orca-remote/runtimes', - expect.any(Function), - undefined - ) - expect(execCommand).toHaveBeenCalledOnce() - const [, command, options] = vi.mocked(execCommand).mock.calls[0] - expect(command).toMatch(/^powershell\.exe /) - expect(options).toMatchObject({ wrapCommand: false }) - expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( - conn, - windowsContext.remoteRelayDir, - expect.stringMatching( - /^C:\/Users\/u\/\.orca-remote\/runtimes\/node-[0-9a-f]{64}\/node\.exe$/ - ), - undefined, - { host: windowsHost, expectPinnedVersion: true } - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-pinned-node-install.ts b/src/main/ssh/ssh-relay-pinned-node-install.ts deleted file mode 100644 index bc0c400218a6..000000000000 --- a/src/main/ssh/ssh-relay-pinned-node-install.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** Host-side steps of the pinned-Node relay install: the runtime store and the pre-launch self-test. */ -import { orcadNodePtyNativeArtifacts } from '../../shared/orcad-artifacts' -import { - ensureRemoteOrcadNodeRuntime, - remoteNodeRuntimeDir, - remoteNodeRuntimePresentCommand, - RemoteNodeRuntimeSecurityModifiedError, - RemoteNodeRuntimeSelfTestError, - REMOTE_NODE_RUNTIME_READY -} from './orcad-remote-node-runtime' -import { - assertRemoteNodeRuntimePromoted, - REMOTE_NODE_RUNTIME_SELFTEST_FAILED -} from './orcad-remote-node-runtime-report' -import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' -import type { SshConnection } from './ssh-connection' -import { shellEscape } from './ssh-connection-utils' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { RelayHostAnsweredError } from './ssh-relay-host-answered-failure' -import { - isPinnedRuntimeRefusal, - PinnedRelayFallbackError, - pinnedRelayNodePath, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { recordPinnedRuntimeRefusal } from './ssh-relay-pinned-refusal-cache' -import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' -import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' -import { - classifyPinnedRuntimeFailure, - runPinnedRuntimeSelfTest -} from './ssh-relay-runtime-self-test' -import { - isWindowsRemoteHost, - joinRemotePath, - remoteDirname, - type RemoteHostPlatform -} from './ssh-remote-platform' - -type PinnedInstallContext = { - conn: SshConnection - host: RemoteHostPlatform - remoteRelayDir: string - plan: PrebuiltRelayPlan - targetId: string - signal?: AbortSignal - run?: RelayRuntimeLadderRun -} - -function refuse(context: PinnedInstallContext, error: PinnedRelayFallbackError): never { - // Only Orca's pinned Node is cached as refused; a host Node refusal names nothing reusable. - if (context.plan.kind === 'pinned-node' && isPinnedRuntimeRefusal(error.reason)) { - recordPinnedRuntimeRefusal(context.targetId, context.plan.target, error.reason) - } - throw error -} - -export function prebuiltRelayNodePath(context: { - host: RemoteHostPlatform - remoteRelayDir: string - plan: PrebuiltRelayPlan -}): string { - return context.plan.kind === 'pinned-node' - ? pinnedRelayNodePath(context.host, context.remoteRelayDir, context.plan.target) - : context.plan.nodePath -} - -/** - * The warm path checks the verified marker and runs `--version` in the same command; hashing - * ~120 MiB on every reconnect would spend the warm-reconnect budget (design D10 G2.6). A cold - * install verifies in full. - */ -export async function ensurePinnedRelayRuntime( - context: PinnedInstallContext & { plan: PinnedRelayPlan }, - relayAlreadyInstalled: boolean -): Promise { - const { conn, host, remoteRelayDir, plan, signal, run } = context - try { - if (relayAlreadyInstalled) { - const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) - // Why run it here: exec policy or a library can change after the install was verified. - const present = await execCommand( - conn, - remoteNodeRuntimePresentCommand(host, runtimeDir, true), - { signal, wrapCommand: !isWindowsRemoteHost(host) } - ) - if (present.trim() === REMOTE_NODE_RUNTIME_READY) { - if (run && run.runtimeTransfer === 'none') { - run.runtimeTransfer = 'cached' - } - return - } - if (present.includes(REMOTE_NODE_RUNTIME_SELFTEST_FAILED)) { - assertRemoteNodeRuntimePromoted(present) - } - } - const { transfer } = await ensureRemoteOrcadNodeRuntime({ - conn, - host, - slotDir: remoteRelayDir, - target: plan.target, - archivePath: plan.runtimeArchive, - signal - }) - if (run && run.runtimeTransfer !== 'uploaded') { - run.runtimeTransfer = transfer - } - } catch (error) { - if (error instanceof PinnedRelayFallbackError) { - refuse(context, error) - } - if (error instanceof RemoteNodeRuntimeSecurityModifiedError) { - refuse(context, new PinnedRelayFallbackError('security_software', error.detail)) - } - if (error instanceof RemoteNodeRuntimeSelfTestError) { - const refusal = classifyPinnedRuntimeFailure(error.exitStatus, error.output) - if (refusal) { - refuse(context, new PinnedRelayFallbackError(refusal, error.message)) - } - } - throw error - } -} - -/** - * The runtime was ensured before the relay dir carried its ref, so a store GC in between could - * have collected it. Now that the ref is visible, a check under the store lock is final: GC only - * deletes while holding that lock, and it never deletes a referenced runtime. - */ -async function confirmPinnedRuntimeHeld( - context: PinnedInstallContext & { plan: PinnedRelayPlan } -): Promise { - const { conn, host, remoteRelayDir, plan, signal } = context - const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) - const present = await withRuntimeStoreLock( - conn, - host, - remoteDirname(runtimeDir, host), - () => - execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { - signal, - wrapCommand: !isWindowsRemoteHost(host) - }), - signal - ) - if (present.trim() !== REMOTE_NODE_RUNTIME_READY) { - console.warn( - `[ssh-relay] Pinned Node runtime vanished before launch; reinstalling ${runtimeDir}` - ) - await ensurePinnedRelayRuntime(context, false) - } -} - -/** Runs after the payload is promoted and before `.install-complete`, so a refused dir never completes. */ -export async function verifyPinnedRelayInstall(context: PinnedInstallContext): Promise { - const { conn, host, remoteRelayDir, plan, signal } = context - // Rung C runs no store runtime, so it has nothing store GC can take. - if (plan.kind === 'pinned-node') { - await confirmPinnedRuntimeHeld({ ...context, plan }) - } - const spawnHelpers = orcadNodePtyNativeArtifacts(plan.target).filter((artifact) => - artifact.endsWith('/spawn-helper') - ) - if (spawnHelpers.length > 0) { - // SFTP drops executable modes; node-pty posix_spawns this helper on macOS. - await execCommand( - conn, - `chmod 755 ${spawnHelpers - .map((artifact) => - shellEscape(joinRemotePath(host, remoteRelayDir, ...artifact.split('/'))) - ) - .join(' ')}`, - { signal } - ) - } - const nodePath = prebuiltRelayNodePath(context) - const verdict = await runPinnedRuntimeSelfTest(conn, remoteRelayDir, nodePath, signal, { - host, - expectPinnedVersion: plan.kind === 'pinned-node' - }) - if (context.run) { - context.run.selfTest = verdict.verdict - } - switch (verdict.verdict) { - case 'passed': - console.log( - `[ssh-relay] ${plan.kind === 'pinned-node' ? 'Pinned' : 'Host'} Node self-test passed at ${remoteRelayDir} (${verdict.report.node}, glibc ${verdict.report.glibcVersionRuntime ?? 'n/a'})` - ) - return - case 'refused': - refuse(context, new PinnedRelayFallbackError(verdict.refusal, verdict.detail)) - break - case 'unverifiable': - // Why rethrow the cause: an unconfirmed teardown must keep the install lock held. - if (isUnconfirmedSshCommandTermination(verdict.cause)) { - throw verdict.cause - } - throw new Error( - `The relay runtime self-test at ${remoteRelayDir} is unverifiable; retrying on the next connect: ${verdict.detail}` - ) - case 'failed': - throw new RelayHostAnsweredError( - `The relay runtime self-test at ${remoteRelayDir} failed: ${verdict.detail}` - ) - } -} diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts deleted file mode 100644 index 7e2387e0850d..000000000000 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { - makeExecResponses, - makeStagedFirstInstallExecPrefix, - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' -import { RELAY_NATIVE_CACHE_LINKED } from './ssh-relay-native-deps-cache-commands' -import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' -import { - computeRelayNativeDepsCacheKey, - RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN -} from './ssh-relay-native-deps-cache' - -const PATCH_ASSET = 'node-pty-1.1.0-master-cloexec-patch.cjs' - -/** - * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and the - * relay leaks a pty fd per terminal (#17915) -- the master into every later child on Linux, an - * orphaned /dev/ptmx throwaway in `pty_posix_spawn` on macOS. The compile that closes both sits on - * the connect path, so what these specs pin is the blast radius, not the patch itself. - */ -describe('relay pty fd-leak patch on the install path', () => { - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - sftpCapture.paths.length = 0 - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of execResponses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { - const prefix = makeStagedFirstInstallExecPrefix() - // The prefix's last slot is the shared native-deps cache probe. - prefix[prefix.length - 1] = cacheAnswer - return [...prefix, ...tail] - } - - function patchCommands(): string[] { - return vi - .mocked(execCommand) - .mock.calls.map(([, command]) => command) - .filter((command) => command.includes(PATCH_ASSET)) - } - - /** Whether this deploy elected itself publisher of the shared entry. */ - function promoted(): boolean { - return vi - .mocked(execCommand) - .mock.calls.some(([, command]) => command.includes('mkdir "$cache"')) - } - - /** - * A cache-miss first install whose patch reports `status`. The promote slot is fed either way, - * so a run that wrongly promotes reads a valid response rather than falling off the end -- the - * assertion has to be the absence of the command itself, not a downstream crash. - */ - function firstInstallReporting(status: string): ExecResponse[] { - return [ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - `ORCA-NPTY-CLOEXEC:${status}\n`, - '', // promote into the shared native-deps cache, if this deploy still gets that far - '', // clean stage root - 'DEAD', - 'READY' - ] - } - - it('runs the patch on a Linux relay once node-pty is proven loadable', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(patchCommands()[0]).toContain("'/usr/bin/node'") - }) - - it('patches the private tree before it is published to the shared native-deps cache', async () => { - // Promotion moves `node_modules` into `~/.orca-remote/native/` and leaves a symlink - // behind, and a published entry is immutable by contract. Patching afterwards would rename, - // rebuild and roll back inside a tree every other relay on the host links -- and the - // `.deps-complete` written by promotion would have published an unpatched tree that every - // later host links and skips. The ordering is invisible in review, so pin it. - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const patchAt = commands.findIndex((command) => command.includes(PATCH_ASSET)) - const promoteAt = commands.findIndex((command) => command.includes('mkdir "$cache"')) - expect(patchAt).toBeGreaterThan(-1) - expect(promoteAt).toBeGreaterThan(-1) - expect(patchAt).toBeLessThan(promoteAt) - }) - - it('does not publish a tree whose patch failed and rolled back', async () => { - // The script rolls `pty.cc` and `build/Release` back to the pre-patch, still-leaky build and - // reports `failed:` with exit 0, so nothing throws. Publishing that tree would be worse than - // the leak this PR closes: the key hashes the patch's bytes, so every later host on the - // machine links the entry, probes it loadable, and skips patching. Stay private instead. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('failed:npm rebuild node-pty failed: gyp ERR! not found: make')) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(promoted()).toBe(false) - expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( - '[ssh-relay][NPTY-CLOEXEC-UNSHARED]' - ) - } finally { - warn.mockRestore() - } - }) - - it('does not publish a tree the patch refused to touch', async () => { - // `skipped:` is not one verdict. Every form except `skipped:unsupported-platform` means the - // patch was declined and the leaky build is still on disk, which is indistinguishable from - // `failed:` as far as what would get published. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('skipped:earlier-attempt-failed')) - - await deployAndLaunchRelay(conn) - - expect(promoted()).toBe(false) - // A refusal exits 0, so the warn is the only signal that this host stayed leaky. - expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( - '[ssh-relay][NPTY-CLOEXEC-UNFIXED]' - ) - } finally { - warn.mockRestore() - } - }) - - it('still publishes a tree that was patched but whose isolation check could not run', async () => { - // `patched-unverified` rebuilt from patched source; only the check that watches a later child - // could not observe the result. An unobservable check is not a failed patch, and refusing to - // publish here would disable the shared cache on every host without `lsof`. - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('patched-unverified')) - - await deployAndLaunchRelay(conn) - - expect(promoted()).toBe(true) - }) - - it('never patches through a symlink into an entry another relay already published', async () => { - // A linked entry was built under a key that hashes this patch's bytes, so it is already - // patched; re-running the patch would rebuild inside the shared tree. - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds, through the symlink - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - '', // clean stage root - 'DEAD', - 'READY' - ]) - ) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toEqual([]) - }) - - it('leaves an unloadable node-pty alone rather than rebuilding it blind', async () => { - // A relay that could not build node-pty has nothing to fall back to, and the existing - // reinstall path owns that repair. - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: 'missing', - repairProbe: 'missing' - }) - ) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toEqual([]) - }) - - it('runs the patch on a macOS relay, which orphans a /dev/ptmx fd per spawn', async () => { - // macOS takes `pty_posix_spawn`, not forkpty, so the asset's original replacements rewrote - // nothing macOS executes -- and this gate answered 'fixed' without running anything, which is - // exactly what publishes to the shared cache. Every later host on the machine then linked a - // tree that leaks one /dev/ptmx fd per terminal, measured +1 per open/close cycle on - // darwin-arm64. macOS pays a first compile here, unlike Linux's second, and that is the price. - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(promoted()).toBe(true) - }) - - it('does not publish a macOS tree whose compile failed', async () => { - // The darwin rollback restores the shipped prebuild, so the relay still works -- and that is - // precisely why the status, not the exit code, has to decide publishability: a rolled-back - // macOS tree probes loadable and still leaks. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('failed:npm rebuild node-pty exited 1: gyp ERR! not ok')) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(promoted()).toBe(false) - } finally { - warn.mockRestore() - } - }) - - it('connects anyway when the patch command fails outright', async () => { - const conn = makeMockConnection(sftpCapture) - const responses = makeExecResponses({ npmInstall: 'ok', probe: 'ok' }) - const patchSlot = responses.findIndex( - (response) => typeof response === 'string' && response.includes('ORCA-NPTY-CLOEXEC:') - ) - expect(patchSlot).toBeGreaterThan(-1) - responses[patchSlot] = { reject: 'no such file or directory' } - feed(responses) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - }) -}) - -describe('the shipped patch is part of the shared native-deps cache key', () => { - it('mints a new entry, so a pre-fix unpatched tree is never linked by a patched build', () => { - const artifact = RELAY_ARTIFACTS.find((entry) => entry.filename === PATCH_ASSET) - expect(artifact).toBeDefined() - // A windowsOnly artifact never reaches a Linux relay dir, so it would drop out of the key. - expect(artifact?.windowsOnly).toBeFalsy() - expect(RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(PATCH_ASSET)).toBe(true) - - const deps = { 'node-pty': '1.1.0' } - expect( - computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps, - patchSources: [{ filename: PATCH_ASSET, contents: 'patch bytes' }] - }) - ).not.toBe(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps })) - }) -}) diff --git a/src/main/ssh/ssh-relay-repair-lock-termination.test.ts b/src/main/ssh/ssh-relay-repair-lock-termination.test.ts deleted file mode 100644 index db73b92e2393..000000000000 --- a/src/main/ssh/ssh-relay-repair-lock-termination.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) - -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked execCommand never reads the connection. -const conn = {} as SshConnection -const mockExec = vi.mocked(execCommand) -const freshLockReplies = ['OPEN', 'LOCKED', '0'] -const contentionReplies = ['OPEN', '', 'BUSY', 'BUSY'] - -const failureStages = [ - { stage: 'initial claim probe', replies: [], recovery: [], result: 'error' }, - { stage: 'parent creation', replies: ['OPEN'], recovery: freshLockReplies, result: 'busy' }, - { stage: 'lock creation', replies: ['OPEN', ''], recovery: freshLockReplies, result: 'busy' }, - { - stage: 'stale takeover', - replies: ['OPEN', '', 'BUSY'], - recovery: freshLockReplies, - result: 'busy' - }, - { - stage: 'claim probe after creation', - replies: ['OPEN', '', 'OK'], - recovery: [''], - result: 'error' - }, - { - stage: 'release after creation', - replies: ['OPEN', '', 'OK', 'LOCKED'], - recovery: [], - result: 'gc' - }, - { - stage: 'claim probe after takeover', - replies: ['OPEN', '', 'BUSY', 'OK'], - recovery: [''], - result: 'error' - }, - { - stage: 'release after takeover', - replies: ['OPEN', '', 'BUSY', 'OK', 'LOCKED'], - recovery: [], - result: 'gc' - }, - { stage: 'contention claim probe', replies: contentionReplies, recovery: [], result: 'error' }, - { - stage: 'contention lock probe', - replies: [...contentionReplies, 'OPEN'], - recovery: [], - result: 'error' - }, - { - stage: 'contention lock age probe', - replies: [...contentionReplies, 'OPEN', 'LOCKED'], - recovery: [], - result: 'error' - } -] - -beforeEach(() => { - mockExec.mockReset() - mockExec.mockResolvedValue('OPEN') - vi.spyOn(console, 'warn').mockImplementation(() => {}) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe.each([ - { platform: 'linux-x64' as const, remoteDir: '/relay/version' }, - { platform: 'win32-x64' as const, remoteDir: 'C:/relay/version' } -])('repair lock termination on $platform', ({ platform, remoteDir }) => { - const host = getRemoteHostPlatform(platform) - - describe.each([false, true])('with caller aborted = %s', (aborted) => { - it.each(failureStages)('stops after unconfirmed $stage', async ({ replies }) => { - const controller = new AbortController() - const error = Object.assign(new Error('SSH teardown not confirmed'), { - sshChannelCloseConfirmed: false - }) - for (const reply of replies) { - mockExec.mockResolvedValueOnce(reply) - } - mockExec.mockImplementationOnce(async () => { - if (aborted) { - controller.abort(new Error('deploy aborted')) - } - throw error - }) - - await expect( - tryAcquireRelayRepairLock(conn, remoteDir, host, { signal: controller.signal }) - ).rejects.toBe(error) - expect(mockExec).toHaveBeenCalledTimes(replies.length + 1) - }) - }) - - it.each(failureStages)( - 'keeps recovery after confirmed $stage failure', - async ({ replies, recovery, result }) => { - for (const reply of replies) { - mockExec.mockResolvedValueOnce(reply) - } - mockExec.mockRejectedValueOnce( - Object.assign(new Error('SSH command failed after closing'), { - sshChannelCloseConfirmed: true - }) - ) - for (const reply of recovery) { - mockExec.mockResolvedValueOnce(reply) - } - - await expect(tryAcquireRelayRepairLock(conn, remoteDir, host)).resolves.toBe(result) - expect(mockExec).toHaveBeenCalledTimes(replies.length + recovery.length + 1) - } - ) - - it('stops when a contention probe after a confirmed acquisition failure is unconfirmed', async () => { - const error = Object.assign(new Error('SSH teardown not confirmed'), { - sshChannelCloseConfirmed: false - }) - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockRejectedValueOnce(new Error('lock creation failed')) - .mockRejectedValueOnce(error) - - await expect(tryAcquireRelayRepairLock(conn, remoteDir, host)).rejects.toBe(error) - expect(mockExec).toHaveBeenCalledTimes(4) - }) -}) diff --git a/src/main/ssh/ssh-relay-repair-lock.ts b/src/main/ssh/ssh-relay-repair-lock.ts deleted file mode 100644 index 9f918a962f0f..000000000000 --- a/src/main/ssh/ssh-relay-repair-lock.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { SshConnection } from './ssh-connection' -import { execHostCommand } from './ssh-relay-host-exec' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { - acquireInstallLockParentCommand, - lockAgeSecondsCommand, - probeInstallLockExistsCommand, - tryCreateInstallLockCommand, - tryStealInstallLockCommand -} from './ssh-relay-install-lock-commands' -import { isRelayGcClaimed } from './ssh-relay-gc-claim' -import { - getRemoteHostPlatform, - joinRemotePath, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { INSTALL_LOCK_STALE_SECONDS, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { removeRemoteTreeCommand } from './ssh-remote-commands' - -const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') - -export type RelayRepairLockResult = 'acquired' | 'busy' | 'gc' | 'error' - -/** - * Try once to acquire the install lock for best-effort repair work. - * - * Why: a completed relay can launch in degraded mode, so repair must not wait - * behind another installer. An already-stale lock is still recovered now. - */ -export async function tryAcquireRelayRepairLock( - conn: SshConnection, - remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { signal?: AbortSignal } -): Promise { - const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) - try { - const gcClaimedBeforeAcquire = await isRelayGcClaimed( - conn, - remoteRelayDir, - host, - options?.signal - ).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return undefined - }) - options?.signal?.throwIfAborted() - if (gcClaimedBeforeAcquire === true) { - return 'gc' - } - if (gcClaimedBeforeAcquire !== false) { - return 'error' - } - await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir), { - signal: options?.signal - }) - const firstAttempt = await execHostCommand( - conn, - host, - tryCreateInstallLockCommand(host, lockDir), - { signal: options?.signal } - ) - if (firstAttempt.trim().endsWith('OK')) { - return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) - } - const steal = await execHostCommand( - conn, - host, - tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), - { signal: options?.signal } - ) - if (steal.trim().endsWith('OK')) { - console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) - return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) - } - return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - options?.signal?.throwIfAborted() - return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) - } -} - -async function classifyRepairLockContention( - conn: SshConnection, - remoteRelayDir: string, - lockDir: string, - host: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return undefined - }) - signal?.throwIfAborted() - if (gcClaimed === true) { - return 'gc' - } - if (gcClaimed !== false) { - return 'error' - } - - const lockProbe = await execHostCommand( - conn, - host, - probeInstallLockExistsCommand(host, lockDir), - { signal } - ).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) - signal?.throwIfAborted() - if (lockProbe.trim() !== 'LOCKED') { - return 'error' - } - const ageOutput = await execHostCommand(conn, host, lockAgeSecondsCommand(host, lockDir), { - signal - }).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) - signal?.throwIfAborted() - const ageSeconds = Number.parseInt(ageOutput.trim(), 10) - // Why: GC may remove stale locks, so only a positively observed fresh lock - // proves that another launch/repair owner is fencing this directory. - return Number.isFinite(ageSeconds) && ageSeconds >= 0 && ageSeconds <= INSTALL_LOCK_STALE_SECONDS - ? 'busy' - : 'error' -} - -async function finishRepairLockAcquire( - conn: SshConnection, - remoteRelayDir: string, - lockDir: string, - host: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return undefined - }) - if (gcClaimed === false && !signal?.aborted) { - return 'acquired' - } - // Why: GC may win its stable sibling claim while this command creates the - // in-tree lock. Back out before npm can mutate a directory being renamed. - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - signal?.throwIfAborted() - return gcClaimed ? 'gc' : 'error' -} diff --git a/src/main/ssh/ssh-relay-reset.test.ts b/src/main/ssh/ssh-relay-reset.test.ts deleted file mode 100644 index 47364fe5ed9a..000000000000 --- a/src/main/ssh/ssh-relay-reset.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createServer, type Server } from 'node:net' -import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -import { forceStopRelayForTarget } from './ssh-relay-reset' -import { execCommand } from './ssh-relay-deploy-helpers' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' -import type { SshConnection } from './ssh-connection' - -const TARGET_PID = '11111' -const UNRELATED_PID = '22222' - -type LsofMode = 'match' | 'empty' - -function writeExecutable(filePath: string, body: string): void { - writeFileSync(filePath, `#!/bin/sh\n${body}\n`, { mode: 0o755 }) -} - -async function listenOnSocket(server: Server, socketPath: string): Promise { - await new Promise((resolve, reject) => { - const onError = (error: Error): void => reject(error) - server.once('error', onError) - server.listen(socketPath, () => { - server.off('error', onError) - resolve() - }) - }) -} - -async function closeServer(server: Server): Promise { - if (!server.listening) { - return - } - await new Promise((resolve) => server.close(() => resolve())) -} - -async function capturedResetScript(): Promise { - const conn = {} as SshConnection - await forceStopRelayForTarget(conn, 'ssh-1') - return vi.mocked(execCommand).mock.lastCall?.[1] ?? '' -} - -async function runResetScript(lsofMode: LsofMode): Promise<{ - killCalls: string[] - pgrepCalls: string[] - socketExists: boolean -}> { - const script = await capturedResetScript() - const home = mkdtempSync(join(tmpdir(), 'orca-')) - const binDir = join(home, 'bin') - const socketDir = join(home, '.orca-remote') - const socketPath = join(socketDir, relaySocketNameForInstanceId('ssh-1')) - const killLog = join(home, 'kill.log') - const pgrepLog = join(home, 'pgrep.log') - mkdirSync(binDir) - mkdirSync(socketDir, { recursive: true }) - writeFileSync(killLog, '') - writeFileSync(pgrepLog, '') - - const lsofBody = - lsofMode === 'empty' - ? 'exit 1' - : `case " $* " in - *" -a "*) printf '%s\\n' "$TARGET_PID" ;; - *) printf '%s\\n' "$TARGET_PID" "$UNRELATED_PID" ;; -esac` - writeExecutable(join(binDir, 'lsof'), lsofBody) - writeExecutable( - join(binDir, 'pgrep'), - `printf 'called\\n' >> "$PGREP_LOG" -printf '%s\\n' "$TARGET_PID"` - ) - writeExecutable(join(binDir, 'sleep'), 'exit 0') - - const server = createServer() - try { - await listenOnSocket(server, socketPath) - execFileSync( - '/bin/sh', - [ - '-c', - `kill() { - printf '%s\\n' "$*" >> "$KILL_LOG" -} -eval "$RESET_SCRIPT"` - ], - { - env: { - ...process.env, - HOME: home, - KILL_LOG: killLog, - PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, - PGREP_LOG: pgrepLog, - RESET_SCRIPT: script, - TARGET_PID, - UNRELATED_PID - } - } - ) - return { - killCalls: readFileSync(killLog, 'utf8').split('\n').filter(Boolean), - pgrepCalls: readFileSync(pgrepLog, 'utf8').split('\n').filter(Boolean), - socketExists: existsSync(socketPath) - } - } finally { - await closeServer(server) - rmSync(home, { recursive: true, force: true }) - } -} - -describe('forceStopRelayForTarget', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('targets only the relay socket for the requested SSH target', async () => { - const conn = {} as SshConnection - - await forceStopRelayForTarget(conn, 'ssh-1') - - const command = vi.mocked(execCommand).mock.calls[0]?.[1] ?? '' - expect(execCommand).toHaveBeenCalledWith(conn, expect.any(String)) - expect(command).toContain(`sock_name='${relaySocketNameForInstanceId('ssh-1')}'`) - expect(command).toContain('lsof -t -a -U "$sock"') - expect(command).toContain('pgrep -f "$sock_name"') - expect(command).toContain('rm -f "$sock"') - }) - - it.skipIf(process.platform === 'win32')( - 'never passes unrelated unix-socket holders to kill', - async () => { - const result = await runResetScript('match') - - expect(result.killCalls).toEqual([`-TERM ${TARGET_PID}`, `-KILL ${TARGET_PID}`]) - expect(result.killCalls.join(' ')).not.toContain(UNRELATED_PID) - expect(result.pgrepCalls).toEqual([]) - expect(result.socketExists).toBe(false) - } - ) - - it.skipIf(process.platform === 'win32')( - 'uses the command-line fallback when lsof cannot match the socket', - async () => { - const result = await runResetScript('empty') - - expect(result.killCalls).toEqual([`-TERM ${TARGET_PID}`, `-KILL ${TARGET_PID}`]) - expect(result.pgrepCalls).toEqual(['called']) - expect(result.socketExists).toBe(false) - } - ) -}) diff --git a/src/main/ssh/ssh-relay-reset.ts b/src/main/ssh/ssh-relay-reset.ts deleted file mode 100644 index 3d585b525f2e..000000000000 --- a/src/main/ssh/ssh-relay-reset.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { SshConnection } from './ssh-connection' -import { shellEscape } from './ssh-connection-utils' -import { execCommand } from './ssh-relay-deploy-helpers' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' -import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit' - -export async function forceStopRelayForTarget( - conn: SshConnection, - relayInstanceId: string -): Promise { - const sockName = relaySocketNameForInstanceId(relayInstanceId) - const escapedSockName = shellEscape(sockName) - const script = [ - `sock_name=${escapedSockName}`, - 'base="${HOME}/.orca-remote"', - // Why: a long $HOME moves the socket to the sun_path-safe short base (#10726); reset must reach it there too. - `short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`, - 'if [ -d "$base" ] || [ -d "$short_base" ]; then', - ' for sock in "$base"/relay-*/"$sock_name" "$base"/"$sock_name" "$short_base"/relay-*/"$sock_name"; do', - ' [ -S "$sock" ] || continue', - ' pid=""', - // Why: lsof ORs selectors by default; -a prevents reset from targeting - // every Unix-socket holder instead of only the per-relay socket (#8762). - ' if command -v lsof >/dev/null 2>&1; then', - ' pid=$(lsof -t -a -U "$sock" 2>/dev/null | tr "\\n" " ")', - ' fi', - ' if [ -z "$pid" ] && command -v pgrep >/dev/null 2>&1; then', - ' pid=$(pgrep -f "$sock_name" 2>/dev/null | ' + - 'awk -v self="$$" -v parent="$PPID" \'$1 != self && $1 != parent\' | tr "\\n" " ")', - ' fi', - ' if [ -n "$pid" ]; then', - ' kill -TERM $pid 2>/dev/null || true', - ' sleep 0.2', - ' kill -KILL $pid 2>/dev/null || true', - ' fi', - ' rm -f "$sock"', - ' done', - 'fi' - ].join('\n') - - await execCommand(conn, script) -} diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts deleted file mode 100644 index 9e0358fa4ca2..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { shellEscape } from './ssh-connection-utils' -import { - REMOTE_RIPGREP_CACHE_DIR_NAME, - REMOTE_RIPGREP_REF_PREFIX -} from './ssh-relay-ripgrep-install' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -export const LIST_OK = '__ORCA_RG_CACHE__LIST_OK' -export const REFS_OK = '__ORCA_RG_CACHE__REFS_OK' -export const REFS_ERR = '__ORCA_RG_CACHE__REFS_ERR' -export const TOMBSTONE_PREFIX = '.rg-gc-' -export const MAX_LISTING_ENTRIES = 64 - -export function cacheDir(host: RemoteHostPlatform, remoteHome: string): string { - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, REMOTE_RIPGREP_CACHE_DIR_NAME) -} - -export function listEntriesCommand(host: RemoteHostPlatform, remoteHome: string): string { - if (isWindowsRemoteHost(host)) { - const dir = powerShellLiteral(cacheDir(host, remoteHome)) - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - `if (-not (Test-Path -LiteralPath ${dir})) { '${LIST_OK}'; exit 0 }`, - - // Why the ENTRY prefix and not bare names: PowerShell writes every uncaptured value to - // stdout, so the token is what separates this listing from anything else a cmdlet emits. - `Get-ChildItem -LiteralPath ${dir} -Directory -Force -ErrorAction Stop | ForEach-Object { 'ENTRY ' + $_.Name }`, - `'${LIST_OK}'` - ].join('\n') - ) - } - const dir = shellEscape(cacheDir(host, remoteHome)) - return [ - `d=${dir}`, - `[ -d "$d" ] || { printf '%s\\n' ${LIST_OK}; exit 0; }`, - `for e in "$d"/* "$d"/${TOMBSTONE_PREFIX}*; do`, - ' [ -d "$e" ] || continue', - ` printf 'ENTRY %s\\n' "$(basename "$e")"`, - 'done', - `printf '%s\\n' ${LIST_OK}` - ].join('\n') -} - -/** - * Every relay directory's recorded ripgrep entry. - * - * A relay directory with no readable marker answers `REFS_ERR`: it may be an older Orca's relay, - * running right now against a binary it never recorded. - */ -export function listReferencesCommand(host: RemoteHostPlatform, remoteHome: string): string { - if (isWindowsRemoteHost(host)) { - const root = powerShellLiteral(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)) - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - `if (-not (Test-Path -LiteralPath ${root})) { '${REFS_OK}'; exit 0 }`, - `$dirs = @(Get-ChildItem -LiteralPath ${root} -Directory -Force -Filter 'relay-*' -ErrorAction Stop)`, - `if ($dirs.Count -ge ${MAX_LISTING_ENTRIES}) { '${REFS_ERR}'; exit 0 }`, - 'foreach ($d in $dirs) {', - // Why PSIsContainer and not `-File`: the remote-payload invariant test greps for that switch, - // because `powershell.exe -File` is execution-policy gated on the host. This is - // Get-ChildItem's unrelated filter, but the guard is worth more than the shorter spelling. - ` $refs = @(Get-ChildItem -LiteralPath $d.FullName -Force -Filter '${REMOTE_RIPGREP_REF_PREFIX}*' -ErrorAction Stop | Where-Object { -not $_.PSIsContainer })`, - `$legacy = Join-Path $d.FullName '.ripgrep-ref'`, - `if (Test-Path -LiteralPath $legacy -PathType Leaf) { 'REF ' + (Get-Content -LiteralPath $legacy -Raw -ErrorAction Stop).Trim() } elseif ($refs.Count -eq 0) { '${REFS_ERR}'; exit 0 }`, - ` foreach ($r in $refs) { 'REF ' + $r.Name.Substring(${REMOTE_RIPGREP_REF_PREFIX.length}) }`, - '}', - `'${REFS_OK}'` - ].join('\n') - ) - } - const root = shellEscape(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)) - return [ - `root=${root}`, - `[ -d "$root" ] || { printf '%s\\n' ${REFS_OK}; exit 0; }`, - `[ -r "$root" ] && [ -x "$root" ] && ls -A "$root" >/dev/null 2>&1 || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, - 'n=0', - 'for d in "$root"/relay-*; do', - ' [ -d "$d" ] || continue', - ` [ -r "$d" ] && [ -x "$d" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, - ' found=0', - ` if [ -e "$d/.ripgrep-ref" ]; then t=$(cat "$d/.ripgrep-ref") || { printf '%s\\n' ${REFS_ERR}; exit 0; }; printf 'REF %s\\n' "$t"; found=1; fi`, - ` for f in "$d"/${REMOTE_RIPGREP_REF_PREFIX}*; do`, - ' [ -f "$f" ] || continue', - ' found=1', - // Why the filename and not the contents: the name is what this client minted, and reading it - // costs nothing extra. The entry is re-validated against ENTRY_NAME before any deletion. - ` printf 'REF %s\\n' "\${f##*/${REMOTE_RIPGREP_REF_PREFIX}}"`, - ' done', - ` [ "$found" = 1 ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, - ' n=$((n+1))', - ` if [ "$n" -ge ${MAX_LISTING_ENTRIES} ]; then printf '%s\\n' ${REFS_ERR}; exit 0; fi`, - 'done', - `printf '%s\\n' ${REFS_OK}` - ].join('\n') -} diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts deleted file mode 100644 index f29a9c52638d..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execMock })) -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' -import { recordRemoteRipgrepReference } from './ssh-relay-ripgrep-install' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' - -const platform = process.platform === 'win32' ? 'win32-x64' : 'linux-x64' -const host = getRemoteHostPlatform(platform) -const FIRST = `1111111111111111-${platform}` -const SECOND = `2222222222222222-${platform}` -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only execCommand touches the connection, and it is replaced with a local shell. -const conn = {} as SshConnection - -function runShell(command: string): string { - return process.platform === 'win32' - ? execFileSync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', decodeRemotePowerShellScript(command)], - { encoding: 'utf8', timeout: 15_000 } - ) - : execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8', timeout: 15_000 }) -} - -describe('ripgrep cache shell transactions', () => { - let home: string - let root: string - let cache: string - let relay: string - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca rg gc ')) - root = join(home, '.orca-remote') - cache = join(root, 'ripgrep') - relay = join(root, 'relay-0.1.0+abc') - mkdirSync(relay, { recursive: true }) - for (const entry of [FIRST, SECOND]) { - mkdirSync(join(cache, entry), { recursive: true }) - writeFileSync(join(cache, entry, 'rg'), entry) - } - execMock - .mockReset() - .mockImplementation(async (_conn: unknown, command: string) => runShell(command)) - }) - - afterEach(() => { - chmodSync(root, 0o755) - rmSync(home, { recursive: true, force: true }) - }) - - it('keeps both binaries recorded in one relay directory and collects them after that directory goes', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - rmSync(relay, { recursive: true }) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache)).toEqual([]) - }) - - it('preserves a legacy reference beside newer per-entry markers', async () => { - writeFileSync(join(relay, '.ripgrep-ref'), FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - }) - - it.each(['', ' \t\r\n'])( - 'blocks collection when a legacy marker is empty or whitespace: %j', - async (contents) => { - writeFileSync(join(relay, '.ripgrep-ref'), contents) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - } - ) - - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'preserves binaries when the relay directory listing is unreadable', - async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - chmodSync(root, 0o111) - await gcRemoteRipgrepCache(conn, host, home) - expect(existsSync(join(cache, FIRST, 'rg'))).toBe(true) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - } - ) - - it('keeps abandoned tombstones while their references are unknown, then collects unreferenced ones', async () => { - const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` - mkdirSync(join(cache, tombstone)) - writeFileSync(join(cache, tombstone, 'rg'), SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(existsSync(join(cache, tombstone, 'rg'))).toBe(true) - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache)).toEqual([FIRST]) - }) - - it('restores an abandoned tombstone that a relay still references', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` - renameSync(join(cache, SECOND), join(cache, tombstone)) - - await gcRemoteRipgrepCache(conn, host, home) - - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - }) - - it('preserves a referenced tombstone when the clock moves backward during the reference scan', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - const now = Date.now() - const tombstone = `.rg-gc-${SECOND}.123.${now - 30 * 60_000 - 1}` - renameSync(join(cache, SECOND), join(cache, tombstone)) - const clock = vi.spyOn(Date, 'now').mockReturnValue(now) - let calls = 0 - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (++calls === 2) { - clock.mockReturnValue(now - 60_000) - } - return runShell(command) - }) - try { - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - } finally { - clock.mockRestore() - } - }) - - it('lets exactly one of two passes claim the same abandoned tombstone', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` - renameSync(join(cache, SECOND), join(cache, tombstone)) - let releaseClaim!: () => void - const claimHeld = new Promise((resolve) => { - releaseClaim = resolve - }) - let heldPassReachedClaim!: () => void - const reachedClaim = new Promise((resolve) => { - heldPassReachedClaim = resolve - }) - let held = false - execMock.mockImplementation(async (_conn: unknown, command: string) => { - const script = process.platform === 'win32' ? decodeRemotePowerShellScript(command) : command - if (!held && script.includes(tombstone)) { - held = true - heldPassReachedClaim() - await claimHeld - } - return runShell(command) - }) - - // The held pass sees SECOND as unreferenced; the other pins it and restores it. - const heldPass = gcRemoteRipgrepCache(conn, host, home) - await reachedClaim - await gcRemoteRipgrepCache(conn, host, home, { pinnedEntry: SECOND }) - releaseClaim() - await heldPass - - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - }) - - it('does not nest a tombstone inside a directory recreated by a concurrent installer', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - let raced = false - execMock.mockImplementation(async (_conn: unknown, command: string) => { - const output = runShell(command) - const script = process.platform === 'win32' ? decodeRemotePowerShellScript(command) : command - if ( - !raced && - (script.startsWith('mv ') || script.includes('Move-Item')) && - script.includes(SECOND) - ) { - raced = true - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - mkdirSync(join(cache, SECOND)) - writeFileSync(join(cache, SECOND, 'rg'), SECOND) - } - return output - }) - await gcRemoteRipgrepCache(conn, host, home) - expect(raced).toBe(true) - expect(readdirSync(join(cache, SECOND))).toEqual(['rg']) - }) -}) diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts deleted file mode 100644 index bc61c5572bab..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { execCommandMock } = vi.hoisted(() => ({ execCommandMock: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execCommandMock })) - -import type { SshConnection } from './ssh-connection' -import { BUNDLED_RIPGREP_PLATFORMS } from '../../shared/bundled-ripgrep' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' - -const LINUX = getRemoteHostPlatform('linux-x64') -const WINDOWS = getRemoteHostPlatform('win32-x64') -const CURRENT = 'c0ffee0123456789-linux-x64' -const SUPERSEDED = 'dead000000000000-linux-x64' - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the GC only issues exec commands; nothing else on the connection is reached. -const conn = {} as unknown as SshConnection - -function scripts(): string[] { - return execCommandMock.mock.calls.map(([, command]) => String(command)) -} - -// Why exclude `find`: the entry listing carries its own `rm -rf` sweep for stale tombstones, and -// counting that as a collection would make every assertion here pass for the wrong reason. -function removedTrees(): string[] { - return scripts().filter( - (s) => s.includes('rm -rf') && s.includes('.rg-gc-') && !s.includes('find ') - ) -} - -function reply(entries: readonly string[], refs: readonly string[] | 'unreadable'): void { - execCommandMock.mockReset() - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve( - `${entries.map((e) => `ENTRY ${e}`).join('\n')}\n__ORCA_RG_CACHE__LIST_OK` - ) - } - if (text.includes('REF %s')) { - return Promise.resolve( - refs === 'unreadable' - ? '__ORCA_RG_CACHE__REFS_ERR' - : `${refs.map((r) => `REF ${r}`).join('\n')}\n__ORCA_RG_CACHE__REFS_OK` - ) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) -} - -describe('remote ripgrep cache GC', () => { - it('collects a build no relay installation references', async () => { - reply([CURRENT, SUPERSEDED], [CURRENT]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toHaveLength(1) - expect(removedTrees()[0]).toContain(SUPERSEDED) - }) - - it.each([LINUX, WINDOWS])( - 'rejects an empty reference on %j before any mutation', - async (host) => { - execCommandMock.mockReset() - execCommandMock - .mockResolvedValueOnce(`ENTRY ${SUPERSEDED}\r\n__ORCA_RG_CACHE__LIST_OK`) - .mockResolvedValueOnce('REF \t\r\n__ORCA_RG_CACHE__REFS_OK') - await gcRemoteRipgrepCache(conn, host, '/home/me') - expect(execCommandMock).toHaveBeenCalledTimes(2) - } - ) - - // Why: the reference is the whole safety argument. A build a live relay was launched against - // must survive, however old its directory is. - it('keeps a referenced build', async () => { - reply([CURRENT, SUPERSEDED], [CURRENT, SUPERSEDED]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why assert the shell and not just the parse: the marker is per entry, so a scan that read a - // single fixed filename would silently see only one of the two references above. - it('scans every marker in a relay directory, not one fixed name', async () => { - execCommandMock.mockReset() - execCommandMock.mockResolvedValue('__ORCA_RG_CACHE__LIST_OK') - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - execCommandMock.mockReset() - execCommandMock.mockImplementation((_c: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) - } - return Promise.resolve('__ORCA_RG_CACHE__REFS_OK') - }) - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - const refScan = scripts().find((s) => s.includes('REF %s')) ?? '' - expect(refScan).toContain('.ripgrep-ref-*') - expect(refScan).toContain('${f##*/.ripgrep-ref-}') - }) - - // Why the whole pass and not just that directory: a relay deployed by an older Orca records no - // reference, so its binary cannot be identified -- and guessing is what breaks a live search. - it('collects nothing when any relay directory cannot be accounted for', async () => { - reply([CURRENT, SUPERSEDED], 'unreadable') - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why a readable RECHECK behind an unreadable first scan: with both unreadable, the restore in - // removeUnreferencedEntry hides a missing up-front guard, and the test passes either way. This - // shape is the only one that fails if the pass stops treating an unaccountable scan as fatal. - it('does not even tombstone when the first reference scan is unreadable', async () => { - execCommandMock.mockReset() - let refCalls = 0 - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) - } - if (text.includes('REF %s')) { - refCalls += 1 - return Promise.resolve( - refCalls === 1 ? '__ORCA_RG_CACHE__REFS_ERR' : '__ORCA_RG_CACHE__REFS_OK' - ) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - expect(removedTrees()).toEqual([]) - expect(scripts().filter((s) => s.includes('MOVED'))).toEqual([]) - }) - - it('keeps the pinned build even when nothing references it yet', async () => { - reply([CURRENT], []) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why restore rather than proceed: a deploy that read the entry as present can still be writing - // its marker, and the recheck is the only place that race becomes visible. - it('restores a tombstoned build when the recheck finds a new reference', async () => { - execCommandMock.mockReset() - let refCalls = 0 - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) - } - if (text.includes('REF %s')) { - refCalls += 1 - return Promise.resolve( - refCalls === 1 - ? '__ORCA_RG_CACHE__REFS_OK' - : `REF ${SUPERSEDED}\n__ORCA_RG_CACHE__REFS_OK` - ) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - expect(removedTrees()).toEqual([]) - // Moved out, then moved back. - expect(scripts().filter((s) => s.includes('MOVED'))).toHaveLength(2) - }) - - // Why tie this to the platform list: a platform the entry pattern did not know would make the - // reference scan read as unreadable, and the pass then collects nothing at all -- every - // superseded build on that host leaks, silently, with no other test failing. - it('accepts an entry for every bundled platform', async () => { - for (const platform of BUNDLED_RIPGREP_PLATFORMS) { - const entry = `c0ffee0123456789-${platform}` - reply([entry], [entry]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - // Referenced, so not collected -- but it had to parse as an entry to be considered at all. - expect(removedTrees()).toEqual([]) - expect(scripts().some((s) => s.includes('REF %s'))).toBe(true) - } - }) - - it('only mints entry names it could have written', async () => { - reply([CURRENT, '../../etc', 'not-an-entry'], [CURRENT]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why Windows gets the same coverage and not a gate: leaving one dialect uncollected means the - // leak simply moves to Windows remotes, where a `.exe` is the larger of the two builds. - it('collects an unreferenced build on Windows remotes too', async () => { - const WIN_CURRENT = 'c0ffee0123456789-win32-x64' - const WIN_OLD = 'dead000000000000-win32-x64' - execCommandMock.mockReset() - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = decodeRemotePowerShellScript(String(command)) ?? String(command) - if (text.includes("'ENTRY '")) { - return Promise.resolve(`ENTRY ${WIN_CURRENT}\nENTRY ${WIN_OLD}\n__ORCA_RG_CACHE__LIST_OK`) - } - if (text.includes("'REF '")) { - return Promise.resolve(`REF ${WIN_CURRENT}\n__ORCA_RG_CACHE__REFS_OK`) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) - - await gcRemoteRipgrepCache(conn, WINDOWS, 'C:/Users/me', { pinnedEntry: WIN_CURRENT }) - - const removals = scripts() - .map((s) => decodeRemotePowerShellScript(s) ?? s) - .filter( - (s) => s.includes('Remove-Item') && s.includes('.rg-gc-') && !s.includes('AddMinutes') - ) - expect(removals).toHaveLength(1) - expect(removals[0]).toContain(WIN_OLD) - }) - - // Why: PowerShell writes every uncaptured value to stdout, so a listing that forgot its token - // prefix would mix cmdlet output into the entry list and feed `Remove-Item` a foreign name. - it('prefixes every Windows listing line with its token', async () => { - execCommandMock.mockReset() - execCommandMock.mockResolvedValue('__ORCA_RG_CACHE__LIST_OK') - - await gcRemoteRipgrepCache(conn, WINDOWS, 'C:/Users/me', {}) - - const listing = decodeRemotePowerShellScript(scripts()[0]) ?? '' - expect(listing).toContain("'ENTRY ' + $_.Name") - expect(listing).toContain('__ORCA_RG_CACHE__LIST_OK') - }) -}) diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts deleted file mode 100644 index 0f479548499e..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - LIST_OK, - REFS_OK, - REFS_ERR, - TOMBSTONE_PREFIX, - MAX_LISTING_ENTRIES, - cacheDir, - listEntriesCommand, - listReferencesCommand -} from './ssh-relay-ripgrep-cache-gc-commands' -// Relay installation references protect binaries until version GC removes their owners. -// Unknown references block deletion; tombstones are rechecked before removal. -import { randomInt } from 'node:crypto' -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { BUNDLED_RIPGREP_PLATFORMS } from '../../shared/bundled-ripgrep' -import { - moveRemoteTreeCommand, - removeRemoteTreeCommand, - restoreRemoteTreeCommand -} from './ssh-remote-commands' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -function entryNamePattern(): RegExp { - const platforms = BUNDLED_RIPGREP_PLATFORMS.map((platform) => - platform.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - ).join('|') - return new RegExp(`^[0-9a-f]{16}-(?:${platforms})$`) -} - -const ENTRY_NAME = entryNamePattern() - -// Recover abandoned deletions only after the owning pass has had time to finish. -function staleTombstoneEntry(name: string): string | null { - if (!name.startsWith(TOMBSTONE_PREFIX)) { - return null - } - const match = /^(.*)\.(\d+)\.(\d+)$/.exec(name.slice(TOMBSTONE_PREFIX.length)) - if ( - !match || - !ENTRY_NAME.test(match[1]) || - !Number.isSafeInteger(Number(match[3])) || - Date.now() - Number(match[3]) < 30 * 60_000 - ) { - return null - } - return match[1] -} - -function exec(conn: SshConnection, host: RemoteHostPlatform, command: string): Promise { - return execCommand(conn, command, { - wrapCommand: !isWindowsRemoteHost(host) - }) -} - -type ReferenceScan = { readable: true; referenced: Set } | { readable: false } - -type CacheEntry = { name: string; entry: string } - -function parseEntries(output: string): CacheEntry[] { - const lines = output.split(/\r?\n/).map((line) => line.trim()) - if (!lines.includes(LIST_OK)) { - return [] - } - const entries: CacheEntry[] = [] - for (const line of lines) { - if (!line.startsWith('ENTRY ')) { - continue - } - const name = line.slice('ENTRY '.length) - // Why re-validate a name the host produced: it is about to be interpolated into `mv` and - // `rm -rf`. Only names this client could itself have minted are eligible. - const entry = ENTRY_NAME.test(name) ? name : staleTombstoneEntry(name) - if (entry && entries.length < MAX_LISTING_ENTRIES) { - entries.push({ name, entry }) - } - } - return entries -} - -async function scanReferences( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string -): Promise { - let output: string - try { - output = await exec(conn, host, listReferencesCommand(host, remoteHome)) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return { readable: false } - } - const lines = output.split(/\r?\n/).map((line) => line.trim()) - // An empty legacy marker is unknown ownership, not an empty reference set. - if (lines.includes(REFS_ERR) || lines.includes('REF') || !lines.includes(REFS_OK)) { - return { readable: false } - } - const referenced = new Set() - for (const line of lines) { - if (!line.startsWith('REF ')) { - continue - } - const name = line.slice('REF '.length).trim() - // An unrecognised marker is a reference this client cannot attribute, so it blocks the pass - // rather than being ignored. - if (!ENTRY_NAME.test(name)) { - return { readable: false } - } - referenced.add(name) - } - return { readable: true, referenced } -} - -/** Collect unreferenced ripgrep builds; unconfirmed termination stops the caller's cleanup. */ -export async function gcRemoteRipgrepCache( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: { pinnedEntry?: string | undefined } = {} -): Promise { - try { - const entries = parseEntries(await exec(conn, host, listEntriesCommand(host, remoteHome))) - if (entries.length === 0) { - return - } - const scan = await scanReferences(conn, host, remoteHome) - if (!scan.readable) { - return - } - const removed: string[] = [] - for (const { name, entry } of entries) { - const keep = scan.referenced.has(entry) || entry === options.pinnedEntry - const collected = - name === entry - ? !keep && (await removeUnreferencedEntry(conn, host, remoteHome, entry)) - : await recoverAbandonedTombstone(conn, host, remoteHome, name, entry, keep) - if (collected) { - removed.push(entry) - } - } - if (removed.length > 0) { - console.log(`[ssh-relay] ripgrep cache GC: removed ${removed.length}: ${removed.join(', ')}`) - } - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - // Confirmed cleanup failures are optional; the next connect tries again. - } -} - -// Why random rather than pid + clock: passes on different clients must never rename into one path. -function ownedTombstonePath(host: RemoteHostPlatform, base: string, entry: string): string { - return joinRemotePath( - host, - base, - `${TOMBSTONE_PREFIX}${entry}.${randomInt(1, 2 ** 47)}.${Date.now()}` - ) -} - -async function moveTree( - conn: SshConnection, - host: RemoteHostPlatform, - source: string, - destination: string -): Promise { - try { - return ( - (await exec(conn, host, moveRemoteTreeCommand(host, source, destination))).trim() === 'MOVED' - ) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return false - } -} - -async function removeUnreferencedEntry( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - entry: string -): Promise { - const base = cacheDir(host, remoteHome) - const entryDir = joinRemotePath(host, base, entry) - const tombstone = ownedTombstonePath(host, base, entry) - if (!(await moveTree(conn, host, entryDir, tombstone))) { - return false - } - return collectOwnedTombstone(conn, host, remoteHome, entry, tombstone, entryDir) -} - -// Only the pass whose rename wins may restore or delete a tombstone other passes can also list. -async function recoverAbandonedTombstone( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - name: string, - entry: string, - keep: boolean -): Promise { - const base = cacheDir(host, remoteHome) - const tombstone = ownedTombstonePath(host, base, entry) - if (!(await moveTree(conn, host, joinRemotePath(host, base, name), tombstone))) { - return false - } - const entryDir = joinRemotePath(host, base, entry) - if (keep) { - await restoreCacheEntry(conn, host, tombstone, entryDir) - return false - } - return collectOwnedTombstone(conn, host, remoteHome, entry, tombstone, entryDir) -} - -async function collectOwnedTombstone( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - entry: string, - tombstone: string, - entryDir: string -): Promise { - // Why recheck under the rename: a deploy that read this entry as present can still be writing - // its marker. Its reference now names a path that no longer exists, so restoring the tree is - // the only outcome that leaves that relay with a working ripgrep. - const recheck = await scanReferences(conn, host, remoteHome).catch(async (err: unknown) => { - // A read-only scan cannot conflict with restoring this pass's renamed tree. - await restoreCacheEntry(conn, host, tombstone, entryDir).catch(() => {}) - throw err - }) - if (!recheck.readable || recheck.referenced.has(entry)) { - await restoreCacheEntry(conn, host, tombstone, entryDir) - return false - } - try { - await exec(conn, host, removeRemoteTreeCommand(host, tombstone)) - return true - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - // A later pass retries after verifying references again. - return false - } -} - -async function restoreCacheEntry( - conn: SshConnection, - host: RemoteHostPlatform, - tombstone: string, - entryDir: string -): Promise { - await exec(conn, host, restoreRemoteTreeCommand(host, tombstone, entryDir)).catch((err) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - }) -} diff --git a/src/main/ssh/ssh-relay-ripgrep-install.test.ts b/src/main/ssh/ssh-relay-ripgrep-install.test.ts deleted file mode 100644 index 990e0ec0d54e..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-install.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - rmSync, - statSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { execCommandMock, uploadRelayDirectoryMock, resolveBundledRipgrepPathMock } = vi.hoisted( - () => ({ - execCommandMock: vi.fn(), - uploadRelayDirectoryMock: vi.fn(), - resolveBundledRipgrepPathMock: vi.fn() - }) -) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execCommandMock })) -vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: uploadRelayDirectoryMock })) -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: resolveBundledRipgrepPathMock, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { ensureRemoteBundledRipgrep, remoteRipgrepLayout } from './ssh-relay-ripgrep-install' - -const LINUX = getRemoteHostPlatform('linux-x64') -const WINDOWS = getRemoteHostPlatform('win32-x64') - -function connection(usesSystemSsh = false): SshConnection { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the install only asks the connection which transport it uses; exec/upload are mocked. - return { usesSystemSshTransport: () => usesSystemSsh } as unknown as SshConnection -} - -function execScripts(): string[] { - return execCommandMock.mock.calls.map(([, command]) => { - const text = String(command) - return text.includes('-EncodedCommand') ? decodeRemotePowerShellScript(text) : text - }) -} - -describe('ensureRemoteBundledRipgrep', () => { - let localDir: string - let localBinary: string - - beforeEach(() => { - execCommandMock.mockReset() - uploadRelayDirectoryMock.mockReset().mockResolvedValue(undefined) - localDir = mkdtempSync(join(tmpdir(), 'orca-local-rg-')) - localBinary = join(localDir, 'linux-x64', 'rg') - mkdirSync(dirname(localBinary)) - writeFileSync(localBinary, 'x'.repeat(1234)) - resolveBundledRipgrepPathMock.mockReset().mockReturnValue(localBinary) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - rmSync(localDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - it('keys the cache on the binary content hash and platform, not the relay version', () => { - expect(remoteRipgrepLayout(LINUX, '/home/me')?.binaryPath).toBe( - '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg' - ) - expect(remoteRipgrepLayout(WINDOWS, 'C:/Users/me user')?.binaryPath).toBe( - 'C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe' - ) - }) - - it('limits Windows cache cleanup to abandoned upload stages', async () => { - execCommandMock.mockResolvedValueOnce('ORCA-RG-PRESENT\n') - - await ensureRemoteBundledRipgrep(connection(), WINDOWS, 'C:/Users/me user') - - const script = execScripts()[0] - expect(script.match(/Get-ChildItem/g)).toHaveLength(1) - expect(script).toContain("-Filter '.upload-*'") - }) - - it('skips the upload in one round trip when the binary is already installed', async () => { - execCommandMock.mockResolvedValueOnce('ORCA-RG-PRESENT\n') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'present' - ) - - expect(execCommandMock).toHaveBeenCalledTimes(1) - expect(execScripts()[0]).toContain( - "-x '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'" - ) - expect(uploadRelayDirectoryMock).not.toHaveBeenCalled() - }) - - // Why the marker matters: it is the only thing that tells the cache GC this build is in use. - // Without it the GC cannot distinguish "nobody uses this" from "nobody recorded it", and the - // safe answer to the second is to collect nothing at all. - it('records the reference before checking or installing its binary', async () => { - execCommandMock.mockResolvedValueOnce('').mockResolvedValueOnce('ORCA-RG-PRESENT\n') - - await ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { - relayDir: '/home/me/.orca-remote/relay-1.2.3' - }) - - const ref = execScripts()[0] - expect(ref).toContain('c0ffee0123456789-linux-x64') - expect(ref).toContain('/home/me/.orca-remote/relay-1.2.3/.ripgrep-ref') - }) - - it('does not inspect or upload the binary after its reference write fails', async () => { - execCommandMock.mockRejectedValueOnce(new Error('read-only relay directory')) - await expect( - ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { - relayDir: '/home/me/.orca-remote/relay-1.2.3' - }) - ).resolves.toBe('failed') - expect(execCommandMock).toHaveBeenCalledTimes(1) - expect(uploadRelayDirectoryMock).not.toHaveBeenCalled() - }) - - it('records nothing when the host has no bundled build to reference', async () => { - resolveBundledRipgrepPathMock.mockReturnValue(null) - - await ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { - relayDir: '/home/me/.orca-remote/relay-1.2.3' - }) - - expect(execScripts().some((script) => script.includes('.ripgrep-ref'))).toBe(false) - }) - - it('uploads into a private stage, then verifies, chmods and renames into place', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockResolvedValueOnce('ORCA-RG-INSTALLED\n') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'installed' - ) - - const [, source, payloadDir, host, options] = uploadRelayDirectoryMock.mock.calls[0] - expect(source).toBe(dirname(localBinary)) - expect(payloadDir).toMatch( - /^\/home\/me\/\.orca-remote\/ripgrep\/\.upload-[0-9a-f]{16}\/payload$/ - ) - expect(host).toBe(LINUX) - // Why: a chrooted SFTP subsystem must resolve the stage through its marker, like relay uploads. - expect(options.sftpNamespace?.homeRelativePath).toBe(payloadDir.slice('/home/me/'.length)) - const promote = execScripts()[1] - expect(promote).toContain('wc -c') - expect(promote).toContain('"1234"') - expect(promote).toContain('chmod 755') - expect(promote).toContain( - `mv -f '${payloadDir}/rg' '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'` - ) - }) - - it('reports an upload failure instead of throwing, and removes its stage', async () => { - execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n').mockResolvedValueOnce('') - uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('sftp: permission denied')) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'failed' - ) - - expect(execScripts()[1]).toMatch(/^rm -rf '\/home\/me\/\.orca-remote\/ripgrep\/\.upload-/) - }) - - describe.each([ - { platform: 'Linux', host: LINUX, home: '/home/me' }, - { platform: 'Windows', host: WINDOWS, home: 'C:/Users/me user' } - ])('interrupted install on $platform', ({ host, home }) => { - it.each([ - ['reference', 1, 0], - ['probe', 1, 0], - ['upload', 1, 1], - ['promotion', 2, 1], - ['cleanup', 2, 1] - ] as const)( - 'preserves unconfirmed %s termination and starts no further operation', - async (phase, commands, uploads) => { - const error = Object.assign(new Error('Remote termination is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - if (phase === 'reference' || phase === 'probe') { - execCommandMock.mockRejectedValueOnce(error) - } else { - execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n') - if (phase === 'upload') { - uploadRelayDirectoryMock.mockRejectedValueOnce(error) - } else { - execCommandMock.mockRejectedValueOnce(error) - if (phase === 'cleanup') { - uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('Upload failed')) - } - } - } - - await expect( - ensureRemoteBundledRipgrep(connection(), host, home, { - relayDir: - phase === 'reference' - ? joinRemotePath(host, home, '.orca-remote', 'relay-1.2.3') - : undefined - }) - ).rejects.toBe(error) - - expect(execCommandMock).toHaveBeenCalledTimes(commands) - expect(uploadRelayDirectoryMock).toHaveBeenCalledTimes(uploads) - } - ) - - it.each(['upload', 'promotion'] as const)( - 'removes the stage after confirmed %s termination and remains nonfatal', - async (phase) => { - const error = Object.assign(new Error('Remote operation stopped'), { - sshChannelCloseConfirmed: true - }) - execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n') - if (phase === 'upload') { - uploadRelayDirectoryMock.mockRejectedValueOnce(error) - } else { - execCommandMock.mockRejectedValueOnce(error) - } - execCommandMock.mockResolvedValueOnce('') - - await expect(ensureRemoteBundledRipgrep(connection(), host, home)).resolves.toBe('failed') - - expect(execCommandMock).toHaveBeenCalledTimes(phase === 'upload' ? 2 : 3) - expect(execScripts().at(-1)).toContain( - host === WINDOWS ? 'Remove-Item -LiteralPath' : 'rm -rf' - ) - } - ) - - it('keeps an ordinary stage cleanup failure nonfatal', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockRejectedValueOnce(new Error('Cleanup failed')) - uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('Upload failed')) - - await expect(ensureRemoteBundledRipgrep(connection(), host, home)).resolves.toBe('failed') - - expect(execCommandMock).toHaveBeenCalledTimes(2) - }) - }) - - it('reports a failed size verification', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockResolvedValueOnce('ORCA-RG-FAILED\n') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'failed' - ) - }) - - it('does nothing when this install has no binary for the host', async () => { - resolveBundledRipgrepPathMock.mockReturnValue(null) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'unavailable' - ) - expect(execCommandMock).not.toHaveBeenCalled() - }) - - it('installs rg.exe on Windows hosts with PowerShell and no SFTP namespace mapping', async () => { - resolveBundledRipgrepPathMock.mockReturnValue(localBinary) - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\r\n') - .mockResolvedValueOnce('ORCA-RG-INSTALLED\r\n') - - await expect( - ensureRemoteBundledRipgrep(connection(), WINDOWS, 'C:/Users/me user') - ).resolves.toBe('installed') - - expect(execCommandMock.mock.calls.every(([, , opts]) => opts.wrapCommand === false)).toBe(true) - const [probe, promote] = execScripts() - expect(probe).toContain( - "Test-Path -LiteralPath 'C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe'" - ) - expect(promote).toContain('Move-Item -LiteralPath $src -Destination $bin') - expect(promote).toContain('.Length -eq 1234') - expect(uploadRelayDirectoryMock.mock.calls[0][4].sftpNamespace).toBeUndefined() - }) - - it('skips the SFTP namespace mapping on the system-ssh transport', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockResolvedValueOnce('ORCA-RG-INSTALLED\n') - - await ensureRemoteBundledRipgrep(connection(true), LINUX, '/home/me') - - expect(uploadRelayDirectoryMock.mock.calls[0][4].sftpNamespace).toBeUndefined() - }) -}) - -// Debian and Ubuntu point /bin/sh at dash; run both so a bashism cannot pass here and fail on a host. -const SHELLS = ['/bin/sh', '/bin/dash'].filter((shell) => existsSync(shell)) - -describe.runIf(process.platform !== 'win32').each(SHELLS)( - 'remote ripgrep install scripts (%s)', - (shell) => { - let home: string - let localDir: string - let localBinary: string - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca-remote-home-')) - localDir = mkdtempSync(join(tmpdir(), 'orca-local-rg-')) - localBinary = join(localDir, 'linux-x64', 'rg') - mkdirSync(dirname(localBinary)) - writeFileSync(localBinary, '#!/bin/sh\necho ripgrep 15.0.0\n') - resolveBundledRipgrepPathMock.mockReset().mockReturnValue(localBinary) - execCommandMock - .mockReset() - .mockImplementation(async (_conn: unknown, command: string) => - execFileSync(shell, ['-c', command], { encoding: 'utf-8' }) - ) - uploadRelayDirectoryMock - .mockReset() - .mockImplementation(async (_conn: unknown, source: string, target: string) => { - cpSync(source, target, { recursive: true }) - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - rmSync(home, { recursive: true, force: true }) - rmSync(localDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - const installed = (): string => - join(home, '.orca-remote', 'ripgrep', 'c0ffee0123456789-linux-x64', 'rg') - const cacheEntries = (): string[] => readdirSync(join(home, '.orca-remote', 'ripgrep')) - - it('installs an executable binary once and leaves no stage behind', async () => { - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') - expect(statSync(installed()).mode & 0o111).not.toBe(0) - expect(execFileSync(installed(), { encoding: 'utf-8' })).toContain('ripgrep 15.0.0') - expect(cacheEntries()).toEqual(['c0ffee0123456789-linux-x64']) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('present') - expect(uploadRelayDirectoryMock).toHaveBeenCalledTimes(1) - }) - - it('never publishes a truncated upload', async () => { - uploadRelayDirectoryMock.mockImplementation( - async (_conn: unknown, source: string, target: string) => { - cpSync(source, target, { recursive: true }) - truncateSync(join(target, 'rg'), 5) - } - ) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('failed') - expect(existsSync(installed())).toBe(false) - expect(cacheEntries()).toEqual([]) - }) - - it('replaces a truncated binary left at the installed path', async () => { - mkdirSync(dirname(installed()), { recursive: true }) - writeFileSync(installed(), '#!/bin/sh\n', { mode: 0o755 }) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') - expect(execFileSync(installed(), { encoding: 'utf-8' })).toContain('ripgrep 15.0.0') - }) - - it('keeps an old binary usable by a relay pinned to an earlier client build', async () => { - const previous = join(home, '.orca-remote', 'ripgrep', 'previous-linux-x64', 'rg') - mkdirSync(dirname(previous), { recursive: true }) - writeFileSync(previous, '#!/bin/sh\necho ripgrep previous\n', { mode: 0o755 }) - execFileSync('touch', ['-t', '200001010000', dirname(previous)]) - expect(execFileSync(previous, { encoding: 'utf-8' })).toContain('ripgrep previous') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('present') - - expect(execFileSync(previous, { encoding: 'utf-8' })).toContain('ripgrep previous') - }) - - it('sweeps an abandoned stage older than an hour but keeps a live one', async () => { - const cache = join(home, '.orca-remote', 'ripgrep') - mkdirSync(join(cache, '.upload-stale', 'payload'), { recursive: true }) - mkdirSync(join(cache, '.upload-live', 'payload'), { recursive: true }) - execFileSync('touch', ['-t', '200001010000', join(cache, '.upload-stale')]) - - await ensureRemoteBundledRipgrep(connection(), LINUX, home) - - expect(cacheEntries().sort()).toEqual(['.upload-live', 'c0ffee0123456789-linux-x64']) - }) - } -) diff --git a/src/main/ssh/ssh-relay-ripgrep-install.ts b/src/main/ssh/ssh-relay-ripgrep-install.ts deleted file mode 100644 index 333a21801bba..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-install.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Installs Orca's own ripgrep on an SSH host so remote Quick Open and text search do not depend - * on the user having `rg`. The binary lives at - * `~/.orca-remote/ripgrep/-/rg[.exe]`, a sibling of the - * `relay-` dirs keyed on the binary's bytes alone, so a relay upgrade never re-uploads it. - * - * Uploads land in a private `.upload-` stage and are renamed into place only after a size - * check, so an interrupted or concurrent deploy never leaves a truncated binary at the final path. - * Ordinary failures use the relay's PATH `rg` and git/readdir fallback; unconfirmed remote stops - * propagate so deployment cannot start more work while the previous operation may still run. - */ -import { randomBytes } from 'node:crypto' -import { statSync } from 'node:fs' -import { dirname } from 'node:path' -import type { SshConnection } from './ssh-connection' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { uploadRelayDirectory } from './ssh-relay-install-transfers' -import { - createRelayUploadStageNamespace, - makeRelayUploadStageDirectoryCommand, - relayUploadStageSftpNamespaceMapping, - type RelayUploadStageNamespace -} from './ssh-relay-install-namespace' -import { shellEscape } from './ssh-connection-utils' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { - assertSafeRemotePathSegment, - isWindowsRemoteHost, - joinRemotePath, - remoteDirname, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { removeRemoteTreeCommand } from './ssh-remote-commands' -import { - bundledRipgrepContentKey, - resolveBundledRipgrepPath -} from '../ripgrep/bundled-ripgrep-path' -import { - bundledRipgrepBinaryName, - toBundledRipgrepPlatform, - type BundledRipgrepPlatform -} from '../../shared/bundled-ripgrep' - -/** Sibling of `relay-`, `orcad-` and `native/`. */ -export const REMOTE_RIPGREP_CACHE_DIR_NAME = 'ripgrep' - -// Ripgrep-only updates share relay bytes, so one relay directory can reference multiple builds. -export const REMOTE_RIPGREP_REF_PREFIX = '.ripgrep-ref-' - -export function remoteRipgrepRefFileName(entryName: string): string { - return `${REMOTE_RIPGREP_REF_PREFIX}${entryName}` -} - -/** Record the relay's ripgrep build; only an unconfirmed remote stop rejects. */ -export async function recordRemoteRipgrepReference( - conn: SshConnection, - host: RemoteHostPlatform, - relayDir: string, - entryName: string -): Promise { - const refPath = joinRemotePath(host, relayDir, remoteRipgrepRefFileName(entryName)) - try { - assertSafeRemotePathSegment(entryName, host.pathFlavor) - await execCommand( - conn, - isWindowsRemoteHost(host) - ? powerShellCommand( - `Set-Content -LiteralPath ${powerShellLiteral(refPath)} -Value ${powerShellLiteral(entryName)} -NoNewline -Encoding ascii` - ) - : `printf %s ${shellEscape(entryName)} > ${shellEscape(refPath)}`, - { wrapCommand: !isWindowsRemoteHost(host) } - ) - return true - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - console.warn( - '[ssh-relay] Could not record the ripgrep reference; skipping the bundled binary:', - error instanceof Error ? error.message : String(error) - ) - return false - } -} -const UPLOAD_STAGE_PREFIX = '.upload-' -// Why an hour: long enough that no live upload of ~5 MB is still writing, short enough to drain crashes. -const STALE_UPLOAD_STAGE_MINUTES = 60 -const PRESENT = 'ORCA-RG-PRESENT' -const STAGED = 'ORCA-RG-STAGED' -const INSTALLED = 'ORCA-RG-INSTALLED' - -export type RemoteRipgrepLayout = { - platform: BundledRipgrepPlatform - cacheDir: string - /** `-`; the sibling directories are superseded builds. */ - entryName: string - binaryPath: string -} - -export type RemoteRipgrepInstallOutcome = 'present' | 'installed' | 'unavailable' | 'failed' - -export function remoteRipgrepLayout( - host: RemoteHostPlatform, - remoteHome: string -): RemoteRipgrepLayout | null { - const platform = toBundledRipgrepPlatform(host.os, host.arch) - const contentKey = platform ? bundledRipgrepContentKey(platform) : null - if (!platform || !contentKey) { - return null - } - const entryName = `${contentKey}-${platform}` - assertSafeRemotePathSegment(entryName, host.pathFlavor) - const cacheDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, REMOTE_RIPGREP_CACHE_DIR_NAME) - return { - platform, - cacheDir, - entryName, - binaryPath: joinRemotePath(host, cacheDir, entryName, bundledRipgrepBinaryName(platform)) - } -} - -/** Ensure the host has Orca's ripgrep; only an unconfirmed remote stop rejects. */ -export async function ensureRemoteBundledRipgrep( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: { signal?: AbortSignal; relayDir?: string } = {} -): Promise { - const layout = remoteRipgrepLayout(host, remoteHome) - if (options.relayDir && layout && resolveBundledRipgrepPath(layout.platform)) { - if (!(await recordRemoteRipgrepReference(conn, host, options.relayDir, layout.entryName))) { - return 'failed' - } - } - return installOrReport(conn, host, remoteHome, options) -} - -async function installOrReport( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: { signal?: AbortSignal } = {} -): Promise { - try { - const layout = remoteRipgrepLayout(host, remoteHome) - const localBinary = layout ? resolveBundledRipgrepPath(layout.platform) : null - if (!layout || !localBinary) { - console.warn( - `[ssh-relay] No bundled ripgrep for ${host.relayPlatform}; the relay will use rg from PATH` - ) - return 'unavailable' - } - return await installRemoteRipgrep(conn, host, layout, localBinary, options.signal) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - console.warn( - '[ssh-relay] Bundled ripgrep install failed; the relay will use rg from PATH:', - error instanceof Error ? error.message : String(error) - ) - return 'failed' - } -} - -async function installRemoteRipgrep( - conn: SshConnection, - host: RemoteHostPlatform, - layout: RemoteRipgrepLayout, - localBinary: string, - signal: AbortSignal | undefined -): Promise { - const exec = (command: string): Promise => - execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal }) - const stageName = `${UPLOAD_STAGE_PREFIX}${randomBytes(8).toString('hex')}` - const stageDir = joinRemotePath(host, layout.cacheDir, stageName) - const stageNamespace = createRelayUploadStageNamespace( - `${RELAY_REMOTE_DIR}/${REMOTE_RIPGREP_CACHE_DIR_NAME}/${stageName}` - ) - - const size = statSync(localBinary).size - // Why one round trip: the warm path (already installed) must cost a single exec. - const probe = await exec(probeOrStageCommand(host, layout, stageDir, stageNamespace, size)) - if (probe.includes(PRESENT)) { - return 'present' - } - if (!probe.includes(STAGED)) { - throw new Error(`could not stage upload: ${probe.trim().slice(0, 200)}`) - } - - let promoted = false - let cleanupAllowed = true - try { - await uploadRelayDirectory( - conn, - dirname(localBinary), - joinRemotePath(host, stageDir, 'payload'), - host, - { - signal, - sftpNamespace: usesOrcaOwnedSftp(conn, host) - ? relayUploadStageSftpNamespaceMapping(stageNamespace, host, stageDir) - : undefined - } - ) - const result = await exec(promoteCommand(host, layout, stageDir, size)) - promoted = true - if (!result.includes(INSTALLED)) { - throw new Error(`upload did not verify: ${result.trim().slice(0, 200)}`) - } - console.log(`[ssh-relay] Installed bundled ripgrep at ${layout.binaryPath} (${size} bytes)`) - return 'installed' - } catch (error) { - cleanupAllowed = !isUnconfirmedSshCommandTermination(error) - throw error - } finally { - if (!promoted && cleanupAllowed) { - // Why best-effort: the next deploy's probe also sweeps stale stages. - await execCommand(conn, removeRemoteTreeCommand(host, stageDir), { - wrapCommand: !isWindowsRemoteHost(host) - }).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - } - } -} - -/** Split shell/SFTP namespaces only arise on POSIX hosts reached over the bundled ssh2 SFTP. */ -function usesOrcaOwnedSftp(conn: SshConnection, host: RemoteHostPlatform): boolean { - if (isWindowsRemoteHost(host)) { - return false - } - return typeof conn.usesSystemSshTransport === 'function' ? !conn.usesSystemSshTransport() : true -} - -// Why size: the path is content-keyed, so a wrong length can only be a truncated or foreign file. -function posixInstalledTest(bin: string, bytes: string): string { - return `[ -f ${bin} ] && [ -x ${bin} ] && [ "$(wc -c < ${bin} 2>/dev/null | tr -d ' \\t')" = "${bytes}" ]` -} - -function windowsInstalledTest(bin: string, bytes: string): string { - return `(Test-Path -LiteralPath ${bin} -PathType Leaf) -and ((Get-Item -LiteralPath ${bin}).Length -eq ${bytes})` -} - -export function probeOrStageCommand( - host: RemoteHostPlatform, - layout: RemoteRipgrepLayout, - stageDir: string, - stageNamespace: RelayUploadStageNamespace, - expectedBytes: number -): string { - const bytes = String(Math.trunc(expectedBytes)) - if (!isWindowsRemoteHost(host)) { - const bin = shellEscape(layout.binaryPath) - const cache = shellEscape(layout.cacheDir) - const sweep = `find ${cache} -mindepth 1 -maxdepth 1 -type d -name '${UPLOAD_STAGE_PREFIX}*' -mmin +${STALE_UPLOAD_STAGE_MINUTES} -exec rm -rf {} + 2>/dev/null` - // Installed builds may still serve an older client's live relay; age is not disuse. - const stage = makeRelayUploadStageDirectoryCommand(stageNamespace, host, stageDir) - // Why the sweep runs before the branch, not inside the else: once rg is installed every later - // deploy takes the PRESENT path, so a stage orphaned by a dropped connection would never be - // collected. It stays one exec round trip either way. - return `${sweep}; if ${posixInstalledTest(bin, bytes)}; then echo ${PRESENT}; else ${stage} && echo ${STAGED}; fi` - } - return powerShellCommand( - [ - `Get-ChildItem -LiteralPath ${powerShellLiteral(layout.cacheDir)} -Directory -Filter '${UPLOAD_STAGE_PREFIX}*' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-${STALE_UPLOAD_STAGE_MINUTES}) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue`, - `if (${windowsInstalledTest(powerShellLiteral(layout.binaryPath), bytes)}) { '${PRESENT}' } else {`, - `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(joinRemotePath(host, stageDir, 'payload'))} -ErrorAction Stop`, - `'${STAGED}' }` - ].join('\n') - ) -} - -/** Verify the staged size, then rename into place; a concurrent winner's binary also counts. */ -export function promoteCommand( - host: RemoteHostPlatform, - layout: RemoteRipgrepLayout, - stageDir: string, - expectedBytes: number -): string { - const staged = joinRemotePath( - host, - stageDir, - 'payload', - bundledRipgrepBinaryName(layout.platform) - ) - if (!isWindowsRemoteHost(host)) { - const src = shellEscape(staged) - const bin = shellEscape(layout.binaryPath) - const bytes = String(Math.trunc(expectedBytes)) - return [ - `if [ "$(wc -c < ${src} 2>/dev/null | tr -d ' \\t')" = "${bytes}" ] && chmod 755 ${src} && mkdir -p ${shellEscape(remoteDirname(layout.binaryPath, host))} && mv -f ${src} ${bin}; then r=${INSTALLED};`, - `elif ${posixInstalledTest(bin, bytes)}; then r=${INSTALLED}; else r=ORCA-RG-FAILED; fi;`, - `rm -rf ${shellEscape(stageDir)}; echo "$r"` - ].join(' ') - } - return powerShellCommand( - [ - `$src = ${powerShellLiteral(staged)}`, - `$bin = ${powerShellLiteral(layout.binaryPath)}`, - "$r = 'ORCA-RG-FAILED'", - 'try {', - `if ((Get-Item -LiteralPath $src -ErrorAction Stop).Length -eq ${Math.trunc(expectedBytes)}) {`, - `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteDirname(layout.binaryPath, host))} -ErrorAction Stop`, - // Why replace only a wrong-size file: a running relay may hold a good rg.exe open. - `if (-not (${windowsInstalledTest('$bin', String(Math.trunc(expectedBytes)))})) { Move-Item -LiteralPath $src -Destination $bin -Force -ErrorAction Stop }`, - `$r = '${INSTALLED}'`, - '}', - `} catch { if (${windowsInstalledTest('$bin', String(Math.trunc(expectedBytes)))}) { $r = '${INSTALLED}' } }`, - `finally { Remove-Item -LiteralPath ${powerShellLiteral(stageDir)} -Recurse -Force -ErrorAction SilentlyContinue }`, - '$r' - ].join('\n') - ) -} diff --git a/src/main/ssh/ssh-relay-runtime-step-plan.ts b/src/main/ssh/ssh-relay-runtime-step-plan.ts deleted file mode 100644 index 69ada96fadd2..000000000000 --- a/src/main/ssh/ssh-relay-runtime-step-plan.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** Turns one ladder step into a relay plan, or a classified refusal the ladder steps past. */ -import type { SshConnection } from './ssh-connection' -import { planHostNodeAddonRelay, type PrebuiltRelayPlan } from './ssh-relay-host-node-addons' -import { - PinnedRelayFallbackError, - planPinnedNodeRelay, - resolvePinnedRelayTargetFacts -} from './ssh-relay-pinned-node' -import { rungBCompatRuntimeFor, type RelayRuntimeStep } from './ssh-relay-runtime-ladder' -import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' -import type { GlibcVersion, OrcadDeploymentTargetFacts } from './orcad-deployment-target' -import type { CompatServerTarget } from '../../shared/node-runtime-pin' - -type StepPlanOptions = { - conn: SshConnection - host: RemoteHostPlatform - baseVersion: string - step: RelayRuntimeStep - run: RelayRuntimeLadderRun - signal?: AbortSignal -} - -/** Resolved once per ladder pass; every rung above legacy keys on the same answer. */ -async function ladderTargetFacts(options: StepPlanOptions): Promise { - const { run } = options - run.facts ??= await resolvePinnedRelayTargetFacts(options) - return run.facts -} - -function planPinned( - options: StepPlanOptions, - facts: OrcadDeploymentTargetFacts, - compat?: { target: CompatServerTarget; glibcFloor: GlibcVersion | null } -): Promise { - const { conn, host, baseVersion, run, signal } = options - return planPinnedNodeRelay({ - conn, - host, - baseVersion, - signal, - targetId: run.targetId, - facts, - persistedRefusal: (known) => run.persistedPinnedRefusal(known), - compat - }) -} - -/** Why strict: only an answered "no Node here" settles D; a lost probe stays a retryable failure. */ -async function proveHostNodeForFallback( - conn: SshConnection, - host: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - try { - return await resolveRemoteNodePath(conn, host, { signal, strict: true }) - } catch (error) { - if (error instanceof RemoteNodeNotFoundError) { - throw new PinnedRelayFallbackError('host_node_missing', 'no host Node.js 18+ with npm') - } - throw error - } -} - -/** Undefined for the host-npm path; throws `PinnedRelayFallbackError` when a rung cannot run. */ -export async function planRelayRuntimeStep( - options: StepPlanOptions -): Promise { - const { conn, host, baseVersion, step, run, signal } = options - run.host = host - switch (step) { - case 'legacy': - if (run.laddered) { - run.hostNodePath = await proveHostNodeForFallback(conn, host, signal) - } - return undefined - case 'A': - return planPinned(options, await ladderTargetFacts(options)) - case 'B': { - if (host.os === 'win32') { - throw new PinnedRelayFallbackError( - 'runtime_unavailable', - 'no compat runtime serves Windows' - ) - } - const facts = await ladderTargetFacts(options) - // run.lastRefusal is rung A's: B is entered only by stepping down from A. - const compat = rungBCompatRuntimeFor(facts, run.lastRefusal) - if (!compat) { - throw new PinnedRelayFallbackError( - 'runtime_unavailable', - 'no compat runtime serves this host' - ) - } - return planPinned(options, facts, { - target: compat.runtimeTarget, - glibcFloor: compat.glibcFloor - }) - } - case 'C': { - if (host.os === 'win32') { - throw new PinnedRelayFallbackError( - 'windows_host_unsupported', - 'Windows hosts have no host-Node addon relay' - ) - } - const plan = await planHostNodeAddonRelay({ - conn, - host, - facts: await ladderTargetFacts(options), - baseVersion, - signal - }) - run.hostNode = plan.hostNode.version - return plan - } - case 'D': - throw new Error('Rung D has no relay to plan') - } -} diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts deleted file mode 100644 index 77b39183f642..000000000000 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ /dev/null @@ -1,802 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' -import type { SshConnection } from './ssh-connection' -import type { MultiplexerTransport } from './ssh-channel-multiplexer' -import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' -import { RelayDispatcher } from '../../wsl-guest/dispatcher' -import { - AGENT_HOOK_NOTIFICATION_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD, - ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV, - REMOTE_AGENT_HOOK_ENV -} from '../../shared/agent-hook-relay' -import { agentHookServer, _internals as agentHookInternals } from '../agent-hooks/server' -import { getSshPtyProvider } from '../ipc/pty' -import { toAppSshPtyId } from '../providers/ssh-pty-id' -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -vi.mock('./ssh-relay-deploy', () => ({ - deployAndLaunchRelay: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const { SshRelaySession } = await import('./ssh-relay-session') - -const SSH_LEAF_ID = '11111111-1111-4111-8111-111111111111' -const REPLAY_LEAF_ID = '22222222-2222-4222-8222-222222222222' -const BAD_LEAF_ID = '33333333-3333-4333-8333-333333333333' -const COMPACT_PROMPT_ID = '44444444-4444-4444-8444-444444444444' -const PREVIOUS_PROMPT_ID = '55555555-5555-4555-8555-555555555555' - -type CapturedStatus = { - paneKey: string - tabId?: string - worktreeId?: string - connectionId: string | null - payload: { - state: string - workingMode?: 'monitoring' - prompt: string - agentType?: string - toolName?: string - } -} - -type FakeRelay = { - transport: MultiplexerTransport - dispatcher: RelayDispatcher - ptySpawnRequests: Record[] - replayEnvelopes: AgentHookRelayEnvelope[] - notifyAgentHook: (envelope: AgentHookRelayEnvelope | Record) => void - dispose: () => void -} - -// Why: mock below SSH at the relay transport boundary so CI covers session, -// mux, provider, and hook-ingest wiring without relying on a local sshd. -function createFakeRelay(): FakeRelay { - let relayFeed: ((data: Buffer) => void) | null = null - const clientDataCallbacks: ((data: Buffer) => void)[] = [] - const clientCloseCallbacks: (() => void)[] = [] - const ptySpawnRequests: Record[] = [] - const replayEnvelopes: AgentHookRelayEnvelope[] = [] - - const transport: MultiplexerTransport = { - write: (data) => { - setImmediate(() => relayFeed?.(data)) - }, - onData: (cb) => { - clientDataCallbacks.push(cb) - }, - onClose: (cb) => { - clientCloseCallbacks.push(cb) - }, - close: () => { - for (const cb of clientCloseCallbacks) { - cb() - } - } - } - - const dispatcher = new RelayDispatcher((data) => { - setImmediate(() => { - for (const cb of clientDataCallbacks) { - cb(data) - } - }) - }) - relayFeed = (data) => dispatcher.feed(data) - - dispatcher.onRequest('pty.openClient', async (params) => ({ - protocolVersion: 1, - serverBuildId: 'test-relay-build', - clientGeneration: 1, - role: 'session-owner', - ownerGeneration: - typeof (params.resume as { ownerGeneration?: unknown } | undefined)?.ownerGeneration === - 'number' - ? (params.resume as { ownerGeneration: number }).ownerGeneration + 1 - : 1, - ownerLease: 'test-owner-lease', - resumed: params.resume !== undefined, - capabilities: { - outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } - } - })) - dispatcher.onRequest('session.resolveHome', async (params) => ({ - resolvedPath: params.path === '~' ? '/home/orca' : params.path - })) - dispatcher.onRequest('git.listWorktrees', async () => []) - dispatcher.onRequest('ports.detect', async () => ({ ports: [], platform: 'linux' })) - dispatcher.onRequest('pty.spawn', async (params) => { - ptySpawnRequests.push(params) - return { id: `remote-pty-${ptySpawnRequests.length}` } - }) - dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { - // Why: relay replay must arrive after Orca wires its listener and before - // the request resolves, matching the real relay ordering contract. - for (const envelope of replayEnvelopes) { - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) - } - return { replayed: replayEnvelopes.length } - }) - - return { - transport, - dispatcher, - ptySpawnRequests, - replayEnvelopes, - notifyAgentHook: (envelope) => { - dispatcher.notify(AGENT_HOOK_NOTIFICATION_METHOD, envelope as Record) - }, - dispose: () => dispatcher.dispose() - } -} - -function createSession(targetId: string): InstanceType { - const store = { - getRepos: vi.fn().mockReturnValue([]), - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn(), - removeSshPtyConsumerRecovery: vi.fn(), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - reconcileSshRemotePtyLeasesForTarget: vi.fn(), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn(), - markSshRemotePtyLeasesAsync: vi.fn(), - markSshRemotePtyLeasesAttachedAsync: vi.fn(), - getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]), - pruneExpiredSshRemotePtyKillIntents: vi.fn(), - recordSshRemotePtyKillIntent: vi.fn(), - clearSshRemotePtyKillIntent: vi.fn(), - noteSshRemotePtyKillReplayAttempt: vi.fn() - } as unknown as Store - const portForwardManager = { - removeAllForwards: vi.fn().mockResolvedValue(undefined) - } as unknown as SshPortForwardManager - const getMainWindow = vi.fn().mockReturnValue({ - isDestroyed: () => false, - webContents: { send: vi.fn() } - }) - return new SshRelaySession(targetId, getMainWindow, store, portForwardManager) -} - -async function waitForStatusCount(events: CapturedStatus[], count: number): Promise { - await vi.waitFor(() => expect(events).toHaveLength(count), { timeout: 1500 }) -} - -function captureAgentStatuses(events: CapturedStatus[]): void { - agentHookServer.setListener((event) => { - events.push({ - paneKey: event.paneKey, - tabId: event.tabId, - worktreeId: event.worktreeId, - connectionId: event.connectionId, - payload: { - state: event.payload.state, - ...(event.payload.workingMode ? { workingMode: event.payload.workingMode } : {}), - prompt: event.payload.prompt, - agentType: event.payload.agentType, - toolName: event.payload.toolName - } - }) - }) -} - -function makeEnvelope(overrides: Partial = {}): AgentHookRelayEnvelope { - return { - source: 'codex', - paneKey: `tab-ssh:${SSH_LEAF_ID}`, - tabId: 'tab-ssh', - worktreeId: 'wt-ssh', - connectionId: null, - env: REMOTE_AGENT_HOOK_ENV, - version: '1', - payload: { - state: 'working', - prompt: 'remote prompt', - agentType: 'codex' - }, - ...overrides - } -} - -describe('SshRelaySession agent hooks over a fake relay transport', () => { - let previousRemoteHooksFlag: string | undefined - let warnSpy: ReturnType - let session: InstanceType | null = null - let relay: FakeRelay | null = null - - beforeEach(() => { - vi.clearAllMocks() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 4 }) - previousRemoteHooksFlag = process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] - process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] = '1' - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - agentHookServer.setListener(null) - agentHookInternals.resetCachesForTests() - }) - - afterEach(() => { - session?.dispose() - relay?.dispose() - session = null - relay = null - agentHookServer.setListener(null) - agentHookServer.setPaneStatusClearListener(null) - agentHookInternals.resetCachesForTests() - warnSpy.mockRestore() - if (previousRemoteHooksFlag === undefined) { - delete process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] - } else { - process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] = previousRemoteHooksFlag - } - }) - - it('establishes through a fake relay, spawns a remote PTY, and forwards agent status', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - - session = createSession('conn-fake') - await session.establish({} as SshConnection) - - const provider = getSshPtyProvider('conn-fake') - expect(provider).toBeDefined() - const spawn = await provider!.spawn({ - cols: 120, - rows: 40, - cwd: '/home/orca/project', - env: { - ORCA_PANE_KEY: `tab-ssh:${SSH_LEAF_ID}`, - ORCA_TAB_ID: 'tab-ssh', - ORCA_WORKTREE_ID: 'wt-ssh' - } - }) - - expect(spawn.id).toBe(toAppSshPtyId('conn-fake', 'remote-pty-1')) - expect(relay.ptySpawnRequests).toHaveLength(1) - expect(relay.ptySpawnRequests[0]).toMatchObject({ - cwd: '/home/orca/project', - env: { - ORCA_PANE_KEY: `tab-ssh:${SSH_LEAF_ID}`, - ORCA_TAB_ID: 'tab-ssh', - ORCA_WORKTREE_ID: 'wt-ssh' - } - }) - - relay.notifyAgentHook(makeEnvelope()) - - await waitForStatusCount(events, 1) - expect(events[0]).toEqual({ - paneKey: `tab-ssh:${SSH_LEAF_ID}`, - tabId: 'tab-ssh', - worktreeId: 'wt-ssh', - connectionId: 'conn-fake', - payload: { - state: 'working', - prompt: 'remote prompt', - agentType: 'codex', - toolName: undefined - } - }) - }) - - it('preserves tmux evidence age and unavailable across the real notification adapter', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - session = createSession('conn-tmux') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Mocked deployment never reads the connection. - await session.establish({} as SshConnection) - const envelope = makeEnvelope({ - source: 'opencode', - evidenceAgeMs: 60_000, - payload: { state: 'done', prompt: 'older inner turn', agentType: 'opencode' } - }) - const before = Date.now() - relay.notifyAgentHook(envelope) - await waitForStatusCount(events, 1) - const row = agentHookServer - .getStatusSnapshot() - .find((entry) => entry.paneKey === envelope.paneKey) - expect(row?.evidenceObservedAt).toBeGreaterThanOrEqual(before - 60_000) - expect(row?.evidenceObservedAt).toBeLessThanOrEqual(Date.now() - 60_000) - relay.notifyAgentHook({ ...envelope, statusUnavailable: true, payload: null }) - await vi.waitFor(() => - expect( - agentHookServer.getStatusSnapshot().find((entry) => entry.paneKey === envelope.paneKey) - ).toBeUndefined() - ) - }) - - it('preserves Claude monitoring mode across the SSH relay boundary', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - session = createSession('conn-monitoring') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - source: 'claude', - claudeRunningNonAgentTask: true, - payload: { - state: 'working', - workingMode: 'monitoring', - prompt: 'watch the build', - agentType: 'claude' - } - }) - ) - - await waitForStatusCount(events, 1) - expect(events[0]).toMatchObject({ - connectionId: 'conn-monitoring', - payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } - }) - expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - workingMode: 'monitoring' - }) - }) - - it('stamps SSH ownership and settles only the exact manual compact identity', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - session = createSession('conn-compact') - await session.establish({} as SshConnection) - - const compactEnvelope = ( - hookEventName: 'UserPromptSubmit' | 'PreCompact' | 'PostCompact', - state: 'working' | 'done' - ): AgentHookRelayEnvelope => - makeEnvelope({ - source: 'claude', - hookEventName, - providerPromptId: - hookEventName === 'UserPromptSubmit' ? PREVIOUS_PROMPT_ID : COMPACT_PROMPT_ID, - compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : 'manual', - providerSession: { key: 'session_id', id: 'claude-session' }, - hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined, - payload: { - state, - prompt: 'work before compact', - agentType: 'claude' - } - }) - - relay.notifyAgentHook(compactEnvelope('UserPromptSubmit', 'working')) - await waitForStatusCount(events, 1) - - // Why: PreCompact fires before the compact is validated, so it may never move the pane — - // over SSH just as locally. - relay.notifyAgentHook(compactEnvelope('PreCompact', 'working')) - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(1) - - relay.notifyAgentHook({ - ...compactEnvelope('PostCompact', 'done'), - providerPromptId: undefined - }) - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(1) - - relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) - await waitForStatusCount(events, 2) - - expect(events.at(-1)).toMatchObject({ - connectionId: 'conn-compact', - payload: { state: 'done', prompt: 'work before compact', agentType: 'claude' } - }) - expect( - agentHookServer._getStateForTests().lastStatusByPaneKey.values().next().value - ).toMatchObject({ - source: 'claude', - providerPromptId: COMPACT_PROMPT_ID, - connectionId: 'conn-compact', - // Why: a compact that finished must not read as a completed turn to notification and - // automation consumers, over SSH just as locally. - payload: { sessionBoundary: true } - }) - - relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(2) - }) - - it('keeps stamped status unverifiable across reconnect loss and final shutdown', async () => { - const initialRelay = createFakeRelay() - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay) - .mockResolvedValueOnce({ - transport: initialRelay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - .mockResolvedValueOnce({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const clearListener = vi.fn() - agentHookServer.setPaneStatusClearListener(clearListener) - session = createSession('conn-clear') - await session.establish({} as SshConnection) - initialRelay.notifyAgentHook(makeEnvelope()) - await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1)) - - await session.reconnect({} as SshConnection) - initialRelay.dispose() - - expect(agentHookServer.getStatusSnapshot()).toEqual([ - expect.objectContaining({ connectionId: 'conn-clear', state: 'working' }) - ]) - expect(clearListener).not.toHaveBeenCalled() - session.dispose() - session = null - expect(clearListener).not.toHaveBeenCalled() - }) - - it('asks the fake relay for cached hook replay after the session wires its listener', async () => { - relay = createFakeRelay() - relay.replayEnvelopes.push( - makeEnvelope({ - paneKey: `tab-replay:${REPLAY_LEAF_ID}`, - tabId: 'tab-replay', - worktreeId: 'wt-replay', - payload: { - state: 'waiting', - prompt: 'cached remote prompt', - agentType: 'claude', - toolName: 'Bash' - } - }) - ) - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - - session = createSession('conn-replay') - await session.establish({} as SshConnection) - - await waitForStatusCount(events, 1) - expect(events[0]).toMatchObject({ - paneKey: `tab-replay:${REPLAY_LEAF_ID}`, - tabId: 'tab-replay', - worktreeId: 'wt-replay', - connectionId: 'conn-replay', - payload: { - state: 'waiting', - prompt: 'cached remote prompt', - agentType: 'claude', - toolName: 'Bash' - } - }) - }) - - it('drops malformed remote hook notifications at Orca main before caching', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - - session = createSession('conn-validate') - await session.establish({} as SshConnection) - - relay.notifyAgentHook({ - source: 'codex', - paneKey: `tab-bad:${BAD_LEAF_ID}`, - connectionId: null, - env: REMOTE_AGENT_HOOK_ENV, - version: '1', - payload: { - state: 'not-a-real-state', - prompt: 'should not be cached', - agentType: 'codex' - } - }) - - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(0) - expect(agentHookServer.getStatusSnapshot()).toEqual([]) - }) - - it('preserves explicit-prompt metadata from remote hook notifications', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - - session = createSession('conn-explicit-prompt') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'retry same prompt', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }) - ) - await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), { - timeout: 1500 - }) - const first = agentHookServer.getStatusSnapshot()[0] - - expect( - agentHookServer.inferInterrupt({ - paneKey: first.paneKey, - baselineUpdatedAt: first.receivedAt, - baselineStateStartedAt: first.stateStartedAt, - baselinePrompt: 'retry same prompt', - baselineAgentType: 'opencode', - intent: 'ctrl-c' - }) - ).toBe(true) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'retry same prompt', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }) - ) - - await vi.waitFor(() => - expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - prompt: 'retry same prompt', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - }) - ) - }) - - it('forwards remote hook transition metadata into main ingest', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') - - session = createSession('conn-hook-metadata') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - source: 'pi', - hookEventName: 'session_start', - promptInteractionKey: 'command-code-transcript-user-3', - toolUseId: 'toolu-1', - toolAgentId: 'agent-subagent-a', - teammateName: 'reviewer', - toolAgentType: 'Review', - claudeRunningNonAgentTask: true, - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'ssh-relay-session-1', - transcriptPath: '/tmp/ssh-relay-session-1.jsonl' - }, - payload: { - state: 'done', - prompt: '', - agentType: 'pi' - } - }) - ) - - await vi.waitFor(() => - expect(ingestSpy).toHaveBeenCalledWith( - expect.objectContaining({ - hookEventName: 'session_start', - promptInteractionKey: 'command-code-transcript-user-3', - toolUseId: 'toolu-1', - toolAgentId: 'agent-subagent-a', - teammateName: 'reviewer', - toolAgentType: 'Review', - claudeRunningNonAgentTask: true, - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'ssh-relay-session-1', - transcriptPath: '/tmp/ssh-relay-session-1.jsonl' - } - }), - 'conn-hook-metadata' - ) - ) - ingestSpy.mockRestore() - }) - - it('tracks prompt sent from live SSH agent hooks but not replayed hooks', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - - session = createSession('conn-live-telemetry') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'ssh live user prompt', - agentType: 'codex' - } - }) - ) - - await vi.waitFor(() => - expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { - agent_kind: 'codex', - launch_source: 'unknown', - request_kind: 'followup', - nth_repo_added: 4 - }) - ) - - trackMock.mockClear() - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - isReplay: true, - payload: { - state: 'working', - prompt: 'ssh replayed user prompt', - agentType: 'codex' - } - }) - ) - - await new Promise((resolve) => setImmediate(resolve)) - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - }) - - it.each([{ isReplay: 'true' }, { isReplay: null }, { launchToken: 42 }])( - 'rejects malformed OMP authority metadata before forwarding: %j', - async (invalid) => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - session = createSession('conn-omp-invalid') - await session.establish({} as SshConnection) - const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') - const envelope = makeEnvelope({ - source: 'omp', - hookEventName: 'before_agent_start', - payload: { agentType: 'omp', state: 'working', prompt: 'new turn' } - }) - relay.notifyAgentHook(JSON.parse(JSON.stringify({ ...envelope, ...invalid }))) - await new Promise((resolve) => setImmediate(resolve)) - await new Promise((resolve) => setImmediate(resolve)) - expect(ingestSpy).not.toHaveBeenCalled() - ingestSpy.mockRestore() - } - ) - - it('preserves replay metadata from remote hook notifications', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - - session = createSession('conn-replay-marker') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'replayed prompt', - agentType: 'opencode' - } - }) - ) - await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), { - timeout: 1500 - }) - const first = agentHookServer.getStatusSnapshot()[0] - - expect( - agentHookServer.inferInterrupt({ - paneKey: first.paneKey, - baselineUpdatedAt: first.receivedAt, - baselineStateStartedAt: first.stateStartedAt, - baselinePrompt: 'replayed prompt', - baselineAgentType: 'opencode', - intent: 'ctrl-c' - }) - ).toBe(true) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - isReplay: true, - payload: { - state: 'working', - prompt: 'replayed prompt', - agentType: 'opencode' - } - }) - ) - - await new Promise((resolve) => setImmediate(resolve)) - expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - prompt: 'replayed prompt', - agentType: 'opencode', - interrupted: true - }) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts deleted file mode 100644 index da528f7f95fb..000000000000 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ /dev/null @@ -1,856 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { - acceptOutputDataMock, - muxRequestMock, - onNotificationByMethodMock, - notifyWithSettlementMock, - openConsumerSessionMock, - pauseAdapterMock, - muxDisposeMock, - attachForReconnectMock, - beginMigrationMock, - ptyDataHandlerRef -} = vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - onNotificationByMethodMock: vi.fn(), - notifyWithSettlementMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - pauseAdapterMock: vi.fn(), - muxDisposeMock: vi.fn(), - attachForReconnectMock: vi.fn().mockResolvedValue({}), - beginMigrationMock: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - ptyDataHandlerRef: { current: undefined as undefined | ((payload: unknown) => void) } -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: beginMigrationMock, - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}), - applySshPtySourceCancellationProof: vi.fn(), - applySshPtySourceRecoveryCancellationProof: vi.fn() -})) - -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = notifyWithSettlementMock - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = onNotificationByMethodMock.mockImplementation(() => () => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = muxDisposeMock - isDisposed = vi.fn().mockReturnValue(false) - } -})) - -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn().mockReturnValue(false), - isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockImplementation((handler) => { - ptyDataHandlerRef.current = handler - return () => {} - }) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = attachForReconnectMock - setPtyDeliveryPauseAdapter = pauseAdapterMock - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { - clearProviderPtyState, - clearPtyOwnershipForConnection, - deletePtyOwnership, - getSshPtyProvider, - getPtyIdsForConnection, - registerSshPtyProvider, - setPtyOwnership -} = await import('../ipc/pty') -const { closeSshPtyOutputGeneration } = await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceCancellationProof } = await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceRecoveryCancellationProof } = - await import('../ipc/ssh-pty-output-intake-registry') -const { getSshPtyAcceptedSourceCheckpoints } = await import('../ipc/ssh-pty-output-intake-registry') -const { installSshPtySourceAckPublisher } = await import('../ipc/ssh-pty-output-intake-registry') -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') - -describe('SshRelaySession data delivery', () => { - beforeEach(() => { - vi.clearAllMocks() - ptyDataHandlerRef.current = undefined - attachForReconnectMock.mockResolvedValue({}) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - vi.mocked(applySshPtySourceCancellationProof).mockReturnValue(true) - vi.mocked(applySshPtySourceRecoveryCancellationProof).mockReturnValue(true) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease', - ...(options.outputFlowControl - ? { - outputFlowControl: { - version: 1, - windowSu: options.outputFlowControl.requestedWindowSu - } - } - : {}) - }, - resumed: options.resume !== undefined - })) - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - async function runRecoverySequence(args: { - targetId: string - recoveryEndSu: number - recoveryFrame?: readonly [startSu: number, endSu: number] - liveFrame?: readonly [startSu: number, endSu: number] - }) { - muxRequestMock.mockImplementation(async (method) => - method === 'pty.cancelDelivery' - ? { canceled: true, sentEndSu: args.recoveryEndSu, creditedEndSu: 4 } - : [] - ) - let generation = 0 - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: ++generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: options.resume !== undefined - })) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: `ssh:${args.targetId}@@pty-1`, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - args.targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([`ssh:${args.targetId}@@pty-1`]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - if (args.recoveryFrame) { - const [sourceStartSu, sourceEndSu] = args.recoveryFrame - ptyDataHandlerRef.current?.({ - id: `ssh:${args.targetId}@@pty-1`, - data: 'recovery', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: sourceEndSu - sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `new-token:${sourceStartSu}:${sourceEndSu}`, - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu, - sourceEndSu - } - }) - } - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: args.recoveryEndSu - }) - if (args.liveFrame) { - const [sourceStartSu, sourceEndSu] = args.liveFrame - ptyDataHandlerRef.current?.({ - id: `ssh:${args.targetId}@@pty-1`, - data: 'live', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: sourceEndSu - sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `new-token:${sourceStartSu}:${sourceEndSu}`, - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu, - sourceEndSu - } - }) - } - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: args.recoveryEndSu - } - } - }) - await session.reconnect(deps.mockConn) - return { ...deps, session } - } - - it('transfers negotiated owner recovery exactly once across an explicit detach', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - const first = new SshRelaySession('recovery-target', getMainWindow, mockStore, mockPortForward) - - await first.establish(mockConn) - first.detach() - const second = new SshRelaySession('recovery-target', getMainWindow, mockStore, mockPortForward) - await second.establish(mockConn) - - const firstOpen = openConsumerSessionMock.mock.calls[0]?.[1] - const recoveredOpen = openConsumerSessionMock.mock.calls[1]?.[1] - expect(recoveredOpen).toMatchObject({ - clientInstanceId: firstOpen.clientInstanceId, - resume: { ownerGeneration: 1, ownerLease: 'test-owner-lease' } - }) - - second.dispose() - const fresh = new SshRelaySession('recovery-target', getMainWindow, mockStore, mockPortForward) - await fresh.establish(mockConn) - const freshOpen = openConsumerSessionMock.mock.calls[2]?.[1] - expect(freshOpen.clientInstanceId).not.toBe(firstOpen.clientInstanceId) - expect(freshOpen).not.toHaveProperty('resume') - fresh.dispose() - }) - - it('resumes authenticated ownership from a persisted main-process recovery record', async () => { - const targetId = 'persisted-recovery-target' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 7, - ownerGeneration: 11, - ownerLease: 'persisted-owner-lease', - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }) - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(openConsumerSessionMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - clientInstanceId: 'persisted-client', - resume: { ownerGeneration: 11, ownerLease: 'persisted-owner-lease' } - }) - ) - expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( - expect.objectContaining({ - targetId, - clientInstanceId: 'persisted-client', - ownerLease: 'test-owner-lease' - }) - ) - session.dispose() - const removeRecovery = mockStore.removeSshPtyConsumerRecovery - expect(removeRecovery).toHaveBeenCalledWith(targetId, 'persisted-client') - }) - - it('voids checkpoints for a fresh claim without a second owner request', async () => { - const targetId = 'fresh-relay-retry' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: `ssh:${targetId}@@pty-1`, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const first = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await first.establish(mockConn) - first.detach() - - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'fresh-owner-lease', - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: false - })) - vi.mocked(getPtyIdsForConnection).mockReturnValue([`ssh:${targetId}@@pty-1`]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } - }) - const second = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - const openCallCountBeforeRetry = openConsumerSessionMock.mock.calls.length - - await second.establish(mockConn) - - const retryCalls = openConsumerSessionMock.mock.calls - .slice(openCallCountBeforeRetry) - .map(([, options]) => options) - // Why one call: the relay answers a proof it cannot match with a fresh claim, so the client never - // needs a second, resume-less request to get owner authority back. - expect(retryCalls).toHaveLength(1) - expect(retryCalls[0]).toHaveProperty('resume') - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - Object.freeze({ status: 'checkpointUnavailable' }) - ) - second.dispose() - }) - - it('delivers empty transformed relay spans with raw sequence metadata', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { onPtyData: vi.fn(() => 17), onPtyExit: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - providerGeneration: number - ptyIncarnation: string - source: { - relayPtyId: string - spanId: string - clientGeneration: number - ownerGeneration: number - deliveryToken: string - sourceStartSu: number - sourceEndSu: number - } - }) => void - const source = { - relayPtyId: 'pty-1', - spanId: 'token-1:0:9', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - sourceStartSu: 0, - sourceEndSu: 9 - } - - onData({ - id: 'ssh-pty-1', - data: '', - sequenceChars: 9, - transformed: true, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - source - }) - - expect(acceptOutputDataMock).toHaveBeenCalledWith({ - id: 'ssh-pty-1', - data: '', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - rawLength: 9, - transformed: true, - source - }) - expect(runtime.onPtyData).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything()) - }) - - it('forwards negotiated source identity to the bounded intake exactly once', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { - id: string - data: string - providerGeneration: number - ptyIncarnation: string - source: { - relayPtyId: string - spanId: string - clientGeneration: number - ownerGeneration: number - deliveryToken: string - sourceStartSu: number - sourceEndSu: number - } - }) => void - const source = { - relayPtyId: 'pty-1', - spanId: 'token-1:0:4', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - sourceStartSu: 0, - sourceEndSu: 4 - } - - onData({ - id: 'ssh-pty-1', - data: 'data', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - source - }) - - expect(acceptOutputDataMock).toHaveBeenCalledOnce() - expect(acceptOutputDataMock).toHaveBeenCalledWith( - expect.objectContaining({ id: 'ssh-pty-1', rawLength: 4, source }) - ) - }) - - it('quarantines missing negotiated source identity before main admission', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = provider.onData.mock.calls[0]?.[0] as (payload: Record) => void - - onData({ - id: 'ssh-pty-1', - data: 'data', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1' - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).not.toHaveBeenCalled() - expect(muxDisposeMock).not.toHaveBeenCalled() - }) - - it('keeps unoffered source metadata out of legacy intake', async () => { - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = provider.onData.mock.calls[0]?.[0] as (payload: Record) => void - - onData({ - id: 'ssh-pty-1', - data: 'data', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - source: { - relayPtyId: 'pty-1', - spanId: 'token-1:0:4', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - sourceStartSu: 0, - sourceEndSu: 4 - } - }) - - expect(acceptOutputDataMock).toHaveBeenCalledWith( - expect.not.objectContaining({ source: expect.anything() }) - ) - }) - - it('keeps same-build method-not-found fallback token-free', async () => { - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(session.getState()).toBe('ready') - expect(pauseAdapterMock).not.toHaveBeenCalled() - expect(openConsumerSessionMock.mock.calls[0][1]).toHaveProperty('outputFlowControl') - expect(installSshPtySourceAckPublisher).not.toHaveBeenCalled() - }) - - it('publishes negotiated ACK batches through mux settlement', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const publish = vi.mocked(installSshPtySourceAckPublisher).mock.calls[0]?.[1] - const settled = vi.fn() - const batch = { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - creditedEndSu: 4 - } - ] - } - - publish?.(batch, settled) - - expect(openConsumerSessionMock.mock.calls[0][1]).toMatchObject({ - outputFlowControl: { requestedWindowSu: 256 * 1024 } - }) - expect(deployAndLaunchRelay).toHaveBeenCalledWith(mockConn, undefined, undefined, 'target-1') - // The ACK publisher consumes the two-valued projection of the write settlement. - const [method, published] = notifyWithSettlementMock.mock.calls[0]! - notifyWithSettlementMock.mock.calls[0]![2]({ outcome: 'accepted' }) - expect([method, published]).toEqual(['pty.ackData', batch]) - expect(settled).toHaveBeenCalledWith({ ok: true }) - }) - - it('offers V1 through reconnect negotiation', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - await session.reconnect(mockConn) - - expect(openConsumerSessionMock.mock.calls[0][1]).toHaveProperty('outputFlowControl') - expect(openConsumerSessionMock.mock.calls[1][1]).toHaveProperty('outputFlowControl') - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 1, - mockConn, - undefined, - undefined, - 'target-1' - ) - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 2, - mockConn, - undefined, - undefined, - 'target-1' - ) - }) - - it('reattaches V1 from an exact checkpoint and quarantines live data until recoveryComplete', async () => { - let generation = 0 - openConsumerSessionMock.mockImplementation(async (_mux, options) => { - generation++ - return { - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: options.resume !== undefined - } - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: 'ssh:target-1@@pty-1', - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1']) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - let transferDisposedMux = false - const publishHeldRecovery = (sink: (payload: unknown) => void): void => { - for (const [data, sourceStartSu, sourceEndSu] of [ - ['re', 4, 6], - ['co', 6, 8], - ['live', 8, 12] - ] as const) { - sink({ - id: 'ssh:target-1@@pty-1', - data, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: sourceEndSu - sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `new-token:${sourceStartSu}:${sourceEndSu}`, - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu, - sourceEndSu - } - }) - } - } - const recoveryActivationLease = { - commit: vi.fn(), - retire: vi.fn() - } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - publishHeldRecovery(sink) - return recoveryActivationLease - }) - } - attachForReconnectMock.mockImplementation(async () => { - const canceled = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.deliveryCanceled' - )?.[1] as ((params: Record) => void) | undefined - const disposeCount = muxDisposeMock.mock.calls.length - canceled?.({ - id: 'pty-1', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - replacementDeliveryToken: 'new-token' - }) - transferDisposedMux = muxDisposeMock.mock.calls.length !== disposeCount - queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, - sourceActivationLease - } - }) - - await session.reconnect(mockConn) - - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - expect.objectContaining({ - status: 'checkpoint', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - }) - ) - expect(acceptOutputDataMock.mock.calls.map(([payload]) => payload.data)).toEqual([ - 're', - 'co', - 'live' - ]) - expect(transferDisposedMux).toBe(false) - expect(sourceActivationLease.transferToRecovery).toHaveBeenCalledOnce() - expect(sourceActivationLease.commit).not.toHaveBeenCalled() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() - expect(recoveryActivationLease.retire).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:replay', expect.anything()) - - const closeCount = vi.mocked(closeSshPtyOutputGeneration).mock.calls.length - const canceled = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.deliveryCanceled' - )?.[1] as ((params: Record) => void) | undefined - canceled?.({ - id: 'pty-1', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token' - }) - expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount) - }) - - it.each([ - ['gap', [5, 8] as const], - ['overlap', [3, 8] as const], - ['incomplete suffix', [4, 6] as const], - ['missing body', undefined] - ])('rejects %s recovery without destroying the physical PTY or lease', async (label, frame) => { - const targetId = `invalid-recovery-${label.replace(' ', '-')}` - const { mockStore, mockWindow } = await runRecoverySequence({ - targetId, - recoveryEndSu: 8, - ...(frame ? { recoveryFrame: frame } : {}) - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(muxRequestMock).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token' - }) - expect(applySshPtySourceRecoveryCancellationProof).toHaveBeenCalledWith( - { - id: `ssh:${targetId}@@pty-1`, - code: -1, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1' - }, - { sentEndSu: 8, creditedEndSu: 4 } - ) - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(targetId, 'pty-1', 'detached') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(targetId, 'pty-1', 'expired') - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - }) - - it('accepts empty recovery only when the checkpoint equals the recovery end', async () => { - const { mockStore } = await runRecoverySequence({ - targetId: 'empty-recovery', - recoveryEndSu: 4, - liveFrame: [4, 8] - }) - - expect(acceptOutputDataMock.mock.calls.map(([payload]) => payload.data)).toEqual(['live']) - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('empty-recovery', [ - 'pty-1' - ]) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-incarnation.test.ts b/src/main/ssh/ssh-relay-session-incarnation.test.ts deleted file mode 100644 index 129a98b3ebc9..000000000000 --- a/src/main/ssh/ssh-relay-session-incarnation.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { acceptOutputExitMock, muxRequestMock } = vi.hoisted(() => ({ - acceptOutputExitMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: vi.fn(async (_mux, options) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: acceptOutputExitMock, - allocateSshPtyProviderGeneration: vi.fn(() => 31), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { registerSshPtyProvider, clearProviderPtyState, deletePtyOwnership, isCurrentPtyExit } = - await import('../ipc/pty') - -describe('SSH relay PTY incarnation exits', () => { - beforeEach(() => { - vi.clearAllMocks() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(isCurrentPtyExit).mockReturnValue(true) - }) - - it('drops a stale exit before ownership cleanup and propagates a current incarnation', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onExit: ReturnType - } - const onExit = provider.onExit.mock.calls[0]?.[0] as (payload: { - id: string - code: number - incarnationId: string - providerGeneration: number - ptyIncarnation: string - }) => void - vi.mocked(isCurrentPtyExit).mockReturnValueOnce(false) - - onExit({ - id: 'ssh:target-1@@pty-reused', - code: 0, - incarnationId: 'old-incarnation', - providerGeneration: 31, - ptyIncarnation: 'old-incarnation' - }) - - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() - expect(acceptOutputExitMock).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - - onExit({ - id: 'ssh:target-1@@pty-reused', - code: 7, - incarnationId: 'current-incarnation', - providerGeneration: 31, - ptyIncarnation: 'current-incarnation' - }) - await vi.waitFor(() => - expect(acceptOutputExitMock).toHaveBeenCalledWith({ - id: 'ssh:target-1@@pty-reused', - code: 7, - providerGeneration: 31, - ptyIncarnation: 'current-incarnation' - }) - ) - expect(runtime.onPtyExit).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts deleted file mode 100644 index 1ef197e170d0..000000000000 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - AGENT_HOOK_INSTALL_PLUGINS_METHOD -} from '../../shared/agent-hook-relay' -import { getDefaultSettings } from '../../shared/constants' -import type { Store } from '../persistence' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn(() => false), - isSshPtyIdentityMismatchError: vi.fn(() => false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn() -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { registerSshPtyProvider } = await import('../ipc/pty') - -describe('SshRelaySession managed hooks', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - mockDeploySuccess() - }) - - it('installs only detected hooks without blocking provider registration', async () => { - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { agents: ['codex'] } - } - return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ? { installers: 1, errors: 0 } - : { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const sftp = vi.fn() - const connection = { - sftp, - getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') - } as unknown as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(connection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, { - hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - agents: ['codex'] - }) - ) - - const managedIndex = muxRequestMock.mock.calls.findIndex( - ([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ) - const pluginsIndex = muxRequestMock.mock.calls.findIndex( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ) - expect(muxRequestMock.mock.calls[pluginsIndex]?.[1]).toMatchObject({ - opencode2PluginSource: expect.stringContaining('/hook/opencode2'), - piExtensionSource: expect.stringContaining('/hook/pi'), - ompExtensionSource: expect.stringContaining('/hook/omp'), - primeAgentExtensionSource: expect.stringContaining('/hook/prime-agent') - }) - expect(sftp).not.toHaveBeenCalled() - expect(muxRequestMock.mock.invocationCallOrder[pluginsIndex]).toBeLessThan( - vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0] - ) - expect(vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]).toBeLessThan( - muxRequestMock.mock.invocationCallOrder[managedIndex] - ) - }) - - it('forwards the execution-host Claude version to the remote installer', async () => { - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { - agents: ['claude'], - versions: { claude: '2.1.261 (Claude Code)' } - } - } - return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ? { installers: 1, errors: 0 } - : { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: establish only reads these mocked connection members in this harness. - const connection = { - sftp: vi.fn(), - getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') - } as unknown as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(connection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, { - hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - agents: ['claude'], - claudeVersion: '2.1.261' - }) - ) - }) - it('refreshes OpenCode sources on settings changes and releases its subscription', async () => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - settings.disabledTuiAgents = ['opencode'] - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - const cleanup = vi.fn(() => { - listener = undefined - }) - mockStore.onSettingsChanged = (callback) => { - listener = callback - return cleanup - } - const session = new SshRelaySession( - 'target-settings', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const lastSources = () => - muxRequestMock.mock.calls.findLast( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - )?.[1] - expect(lastSources()).toMatchObject({ - opencodePluginSource: '', - opencode2PluginSource: expect.stringContaining('/hook/opencode2') - }) - settings.disabledTuiAgents = ['opencode2'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - expect(lastSources()).toMatchObject({ - opencodePluginSource: expect.stringContaining('/hook/opencode'), - opencode2PluginSource: '' - }) - settings.agentStatusHooksEnabled = false - listener?.({ agentStatusHooksEnabled: false }, settings) - expect(lastSources()).toMatchObject({ opencodePluginSource: '', opencode2PluginSource: '' }) - session.dispose() - expect(cleanup).toHaveBeenCalledOnce() - }) - - // Why: a timed-out install left the relay on the old sources (a disabled agent kept its plugin) until the next reconnect. - it('retries a settings-triggered plugin install that failed, using the latest settings', async () => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-settings', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const installCalls = () => - muxRequestMock.mock.calls.filter(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - - vi.useFakeTimers() - try { - const before = installCalls().length - muxRequestMock.mockImplementationOnce(async (method: string) => { - if (method !== AGENT_HOOK_INSTALL_PLUGINS_METHOD) { - return { agents: [] } - } - throw Object.assign(new Error('timed out'), { code: 'SSH_MUX_REQUEST_TIMEOUT' }) - }) - settings.disabledTuiAgents = ['opencode2'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - await vi.advanceTimersByTimeAsync(60_000) - - const calls = installCalls().slice(before) - expect(calls.length).toBeGreaterThan(1) - expect(calls.at(-1)?.[1]).toMatchObject({ opencode2PluginSource: '' }) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - }) - - // Why: two overlapping failed installs must leave one retry chain, not two the next install can't cancel. - it('retries once when overlapping settings-triggered installs both fail', async () => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-settings', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const installCalls = () => - muxRequestMock.mock.calls.filter(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - - vi.useFakeTimers() - try { - const before = installCalls().length - const pendingInstalls: ((error: Error) => void)[] = [] - muxRequestMock.mockImplementation((method: string) => { - if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD && pendingInstalls.length < 2) { - return new Promise((_resolve, reject) => pendingInstalls.push(reject)) - } - return Promise.resolve({ agents: [] }) - }) - settings.disabledTuiAgents = ['opencode2'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - settings.disabledTuiAgents = ['opencode'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - expect(pendingInstalls).toHaveLength(2) - - for (const reject of pendingInstalls) { - reject(Object.assign(new Error('timed out'), { code: 'SSH_MUX_REQUEST_TIMEOUT' })) - } - await vi.advanceTimersByTimeAsync(60_000) - - expect(installCalls().slice(before)).toHaveLength(3) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - }) - it.each(['PERMISSION_DENIED', -32601, 'CONNECTION_LOST', 'DISPOSED'])( - 'does not retry permanent or retired transport failure %s', - async (code) => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-no-retry', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - warn.mockClear() - vi.useFakeTimers() - try { - const before = muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ).length - muxRequestMock.mockRejectedValueOnce(Object.assign(new Error('controlled error'), { code })) - listener?.({ disabledTuiAgents: ['opencode2'] }, settings) - await vi.advanceTimersByTimeAsync(60_000) - expect( - muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ) - ).toHaveLength(before + 1) - expect(warn).toHaveBeenCalledTimes(code === 'PERMISSION_DENIED' ? 1 : 0) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - } - ) - it.each([false, true])( - 'bounds timeout retries and cancels them on disposal=%s', - async (dispose) => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-bounded', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const before = muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ).length - muxRequestMock.mockImplementation(async (method) => { - if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) { - throw Object.assign(new Error('timeout'), { code: 'SSH_MUX_REQUEST_TIMEOUT' }) - } - return { agents: [] } - }) - vi.useFakeTimers() - try { - listener?.({ disabledTuiAgents: ['opencode2'] }, settings) - if (dispose) { - session.dispose() - } - await vi.advanceTimersByTimeAsync(60_000) - expect( - muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ) - ).toHaveLength(before + (dispose ? 1 : 4)) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - } - ) -}) diff --git a/src/main/ssh/ssh-relay-session-model-migration.test.ts b/src/main/ssh/ssh-relay-session-model-migration.test.ts deleted file mode 100644 index ae5db55e0cc3..000000000000 --- a/src/main/ssh/ssh-relay-session-model-migration.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -type SettledMigration = { - status: 'settled' - checkpoint: { - id: string - providerGeneration: number - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string - acceptedSourceEndSu: number - } -} - -const { attachForReconnectMock, beginMigrationMock, muxRequestMock, openConsumerSessionMock } = - vi.hoisted(() => ({ - attachForReconnectMock: vi.fn().mockResolvedValue({}), - beginMigrationMock: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn() - })) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: beginMigrationMock, - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}), - applySshPtySourceCancellationProof: vi.fn(), - applySshPtySourceRecoveryCancellationProof: vi.fn() -})) - -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = attachForReconnectMock - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { getPtyIdsForConnection, getSshPtyProvider, registerSshPtyProvider } = - await import('../ipc/pty') -const { getSshPtyAcceptedSourceCheckpoints } = await import('../ipc/ssh-pty-output-intake-registry') - -function pendingMigration() { - let resolve!: (result: SettledMigration) => void - const result = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { result, resolve } -} - -function settledMigration(appPtyId: string, acceptedSourceEndSu: number): SettledMigration { - return { - status: 'settled', - checkpoint: { - id: appPtyId, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu - } - } -} - -describe('SshRelaySession model migration', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'owner-lease-1', - ...(options.outputFlowControl - ? { - outputFlowControl: { - version: 1, - windowSu: options.outputFlowControl.requestedWindowSu - } - } - : {}) - }, - resumed: options.resume !== undefined - })) - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - it('keeps overlapping reconnects behind the old-generation model migration', async () => { - const targetId = 'migration-fence' - const appPtyId = `ssh:${targetId}@@pty-1` - const migration = pendingMigration() - beginMigrationMock.mockReturnValueOnce({ - byPty: new Map([[appPtyId, migration.result]]), - completion: migration.result.then(() => {}) - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: appPtyId, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([appPtyId]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'relayRestoreRequired' } - }) - - const firstReconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(registerSshPtyProvider).toHaveBeenCalledTimes(2)) - const secondReconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(beginMigrationMock).toHaveBeenCalledTimes(2)) - expect(attachForReconnectMock).not.toHaveBeenCalled() - - migration.resolve(settledMigration(appPtyId, 8)) - await Promise.all([firstReconnect, secondReconnect]) - - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - Object.freeze({ - status: 'checkpoint', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 8 - }) - ) - }) - - it('waits for a voided-checkpoint migration before requesting restore', async () => { - const targetId = 'migration-stale-owner' - const appPtyId = `ssh:${targetId}@@pty-1` - const migration = pendingMigration() - beginMigrationMock.mockReturnValueOnce({ - byPty: new Map([[appPtyId, migration.result]]), - completion: migration.result.then(() => {}) - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - settledMigration(appPtyId, 4).checkpoint - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([appPtyId]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 2, - ownerGeneration: 2, - ownerLease: 'fresh-owner-lease', - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: false - })) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(openConsumerSessionMock).toHaveBeenCalledTimes(2)) - expect(attachForReconnectMock).not.toHaveBeenCalled() - - migration.resolve(settledMigration(appPtyId, 8)) - await reconnect - - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - Object.freeze({ status: 'checkpointUnavailable' }) - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-orphan-sweep.test.ts b/src/main/ssh/ssh-relay-session-orphan-sweep.test.ts deleted file mode 100644 index fd4cbea8c341..000000000000 --- a/src/main/ssh/ssh-relay-session-orphan-sweep.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -// #9819 end to end on the client: the sweep runs only after reattach, only under a negotiated -// session-owner grant, and only against PTYs this relay itself attributes to this client. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(async (_mux: unknown, options: { clientInstanceId: string }) => ({ - state: { - mode: 'negotiated' as const, - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - }, - resumed: false - })) -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 17), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-remote-orca-cli', () => ({ - runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn() -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { getSshPtyProvider, getPtyIdsForConnection } = await import('../ipc/pty') - -const OUR_CLIENT = 'client-instance-1' - -// One target per test. `claimSshPtyConsumerRecovery` keeps a module-level map keyed on target and -// mints a FRESH clientInstanceId whenever it is asked for a target it already holds a live entry -// for — so a second `establish` on a shared target silently stops matching the host attestation and -// every assertion after the first passes for the wrong reason. -let targetSeq = 0 -function nextTarget(): string { - targetSeq += 1 - return `target-${targetSeq}` -} - -const OBSERVATION = { authorityGeneration: 'gen-1', observationEpoch: 1, capturedAgeMs: 0 } - -function hostEntry( - target: string, - overrides: Record = {} -): Record { - return { - id: `ssh:${target}@@pty-orphan`, - incarnationId: 'inc-orphan', - cwd: '/home/user', - title: 'zsh', - // The relay stamps this from the live consumer grant, so it names THIS client. - ownerClientInstanceId: OUR_CLIENT, - hostAgeMs: 120_000, - paneBound: true, - // The same listing's host observation: the shell owns the terminal, nothing is running. - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'live', - processName: null, - shellOwnsEveryTtyProcessGroup: true - }, - ...overrides - } -} - -describe('SshRelaySession orphaned relay PTY sweep', () => { - let warn: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - afterEach(() => { - warn.mockRestore() - }) - - async function establish( - target: string, - processes: Record[], - leases: { ptyId: string; state: string }[] = [] - ): Promise<{ shutdown: ReturnType; listProcesses: ReturnType }> { - const deps = createMockDeps() - // Why the recovery row: it pins this session's clientInstanceId, and the comparison is - // meaningless unless the id it uses is the persisted one. - vi.mocked(deps.mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId: target, - clientInstanceId: OUR_CLIENT, - serverBuildId: 'build-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - } as ReturnType) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue( - leases.map((lease) => ({ targetId: target, ...lease })) as ReturnType< - typeof deps.mockStore.getSshRemotePtyLeases - > - ) - const shutdown = vi.fn().mockResolvedValue(undefined) - const listProcesses = vi.fn().mockResolvedValue(processes) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({}), - listProcesses, - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - - const session = new SshRelaySession( - target, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - // Both guards exist because every "never stops" case below is trivially satisfiable. The pass - // has to have run, and it has to have run under the identity the host attests — a session that - // minted a fresh one compares against nothing and skips everything for the wrong reason. - expect(listProcesses).toHaveBeenCalledTimes(1) - expect(warn).not.toHaveBeenCalledWith( - expect.stringContaining('minting a new consumer identity') - ) - return { shutdown, listProcesses } - } - - it('stops an attested orphan the client has no lease for', async () => { - const target = nextTarget() - const { shutdown } = await establish(target, [hostEntry(target)]) - - expect(shutdown).toHaveBeenCalledWith( - `ssh:${target}@@pty-orphan`, - expect.objectContaining({ immediate: true, expectedIncarnationId: 'inc-orphan' }) - ) - }) - - it('never stops a PTY that still holds a live lease', async () => { - const target = nextTarget() - const { shutdown } = await establish( - target, - [hostEntry(target, { id: `ssh:${target}@@pty-live`, incarnationId: 'inc-live' })], - [{ ptyId: 'pty-live', state: 'detached' }] - ) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a PTY whose lease this client expired rather than ordered stopped', async () => { - // What reaches this state in the field: a pane re-leased under a new relay id, a reattach that - // failed on the transport (dropStalePty), or a pane surface missing from the layout. All three - // leave the remote process running on purpose. - const target = nextTarget() - const { shutdown } = await establish( - target, - [hostEntry(target, { id: `ssh:${target}@@pty-gone`, incarnationId: 'inc-gone' })], - [{ ptyId: 'pty-gone', state: 'expired' }] - ) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a pane this relay observes running a foreground process', async () => { - // agentSessionOwners is empty here — the user typed `claude` themselves — so the only thing - // between a live agent and a stop is the host's own foreground observation. - const target = nextTarget() - const { shutdown } = await establish(target, [ - hostEntry(target, { - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'live', - processName: 'claude', - shellOwnsEveryTtyProcessGroup: false - } - }) - ]) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a pane whose foreground observation the relay could not make', async () => { - const target = nextTarget() - const { shutdown } = await establish(target, [ - hostEntry(target, { - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'unverifiable', - reason: 'table_unreadable' - } - }) - ]) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a PTY this relay attributes to a different client', async () => { - const target = nextTarget() - const { shutdown } = await establish(target, [ - hostEntry(target, { ownerClientInstanceId: 'someone-elses-laptop' }) - ]) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops anything a relay predating the attestation lists', async () => { - const target = nextTarget() - const legacy = hostEntry(target) - delete legacy.ownerClientInstanceId - delete legacy.hostAgeMs - delete legacy.paneBound - delete legacy.foregroundProcessEvidence - - const { shutdown } = await establish(target, [legacy]) - - expect(shutdown).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts b/src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts deleted file mode 100644 index 5eeb78fdea1e..000000000000 --- a/src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { muxRequestMock, openConsumerSessionMock, listProcessesMock, shutdownMock } = vi.hoisted( - () => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - listProcessesMock: vi.fn(), - shutdownMock: vi.fn() - }) -) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined), - attachForReconnect: vi.fn().mockResolvedValue({}), - listProcesses: listProcessesMock, - shutdown: shutdownMock - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const NOW = 1_800_000_000_000 - -describe('SshRelaySession pending PTY kill replay', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.spyOn(console, 'log').mockImplementation(() => {}) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - it('replays a stop the client could not deliver, before reattach reads the leases', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const order: string[] = [] - vi.mocked(mockStore.getSshRemotePtyKillIntents).mockReturnValue([ - { ptyId: 'pty-3', intent: { requestedAt: NOW, incarnationId: 'inc-a', attempts: 0 } } - ]) - vi.mocked(mockStore.getSshRemotePtyLeases).mockImplementation(() => { - order.push('read-leases') - return [] - }) - let live = true - listProcessesMock.mockImplementation(async () => - live ? [{ id: 'ssh:target-1@@pty-3', incarnationId: 'inc-a' }] : [] - ) - shutdownMock.mockImplementation(async (id: string) => { - order.push(`shutdown:${id}`) - live = false - }) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(shutdownMock).toHaveBeenCalledWith('ssh:target-1@@pty-3', { - immediate: true, - expectedIncarnationId: 'inc-a' - }) - expect(order).toEqual(['shutdown:ssh:target-1@@pty-3', 'read-leases']) - expect(mockStore.clearSshRemotePtyKillIntent).toHaveBeenCalledWith('target-1', 'pty-3') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-3', 'terminated') - }) - - it('costs no host round trip when nothing is pending', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(listProcessesMock).not.toHaveBeenCalled() - expect(shutdownMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts deleted file mode 100644 index a850fa275087..000000000000 --- a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ClientChannel } from 'ssh2' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { createMockDeps } from './ssh-relay-session-test-fixtures' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError -} from './ssh-relay-runtime-resolution' -import { getSshPlainSshMode } from './ssh-plain-ssh-mode' -import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' -import { SshSftpFilesystemProvider } from '../providers/ssh-sftp-filesystem-provider' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type * as IntakeRegistry from '../ipc/ssh-pty-output-intake-registry' - -type IntakeRegistryModule = typeof IntakeRegistry - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn().mockReturnValue(true) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', async (importOriginal) => ({ - ...(await importOriginal()), - acceptSshPtyOutputData: vi.fn().mockResolvedValue({}), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - closeSshPtyOutputGeneration: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const pty = await import('../ipc/pty') -const fsDispatch = await import('../providers/ssh-filesystem-dispatch') -const intake = await import('../ipc/ssh-pty-output-intake-registry') - -class FakeShellChannel extends EventEmitter { - readonly stderr = new EventEmitter() - readonly write = vi.fn() - readonly setWindow = vi.fn() - readonly close = vi.fn() -} - -function runtimeUnavailable(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('target-1', null, true) - run.host = getRemoteHostPlatform('linux-x64') - run.refused('A', 'libc_floor') - run.refused('C', 'host_node_missing') - return new RemoteRuntimeUnavailableError(run) -} - -function createConnection(systemSsh = false) { - const channel = new FakeShellChannel() - const conn = { - usesSystemSshTransport: () => systemSsh, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeShellChannel implements every ClientChannel member the provider touches. - shell: vi.fn(async () => channel as unknown as ClientChannel), - sftp: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Plain SSH mode only calls usesSystemSshTransport, shell and sftp on the connection. - return { conn: conn as unknown as SshConnection, channel, shell: conn.shell } -} - -function registeredPlainProvider(): SshPlainShellPtyProvider { - const provider = vi.mocked(pty.registerSshPtyProvider).mock.calls[0]?.[1] - if (!(provider instanceof SshPlainShellPtyProvider)) { - throw new Error('plain SSH PTY provider was not registered') - } - return provider -} - -function createSession() { - const deps = createMockDeps() - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - return { session, deps } -} - -describe('SshRelaySession plain SSH mode (runtime rung D)', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(deployAndLaunchRelay).mockRejectedValue(runtimeUnavailable()) - }) - - it('connects with shell and SFTP providers and records the classified reason', async () => { - const { session } = createSession() - const onReady = vi.fn() - session.setOnReady(onReady) - const { conn } = createConnection() - - await session.establish(conn) - - expect(session.getState()).toBe('ready') - expect(onReady).toHaveBeenCalledWith('target-1') - expect(session.getMux()).toBeNull() - expect(vi.mocked(pty.registerSshPtyProvider).mock.calls[0]?.[1]).toBeInstanceOf( - SshPlainShellPtyProvider - ) - expect(vi.mocked(fsDispatch.registerSshFilesystemProvider).mock.calls[0]?.[1]).toBeInstanceOf( - SshSftpFilesystemProvider - ) - expect(getSshPlainSshMode('target-1')).toMatchObject({ reason: 'no_runtime' }) - expect(getSshPlainSshMode('target-1')?.message).toContain('plain SSH terminals') - }) - - it('keeps the OS the runtime ladder detected so agent launches quote for that host', async () => { - const { session } = createSession() - const { conn } = createConnection() - - await session.establish(conn) - - expect(session.getHostPlatform()).toBeNull() - expect(session.getPlainSshSession()?.remotePlatform).toBe('linux') - }) - - it('routes shell output and a proven exit through the SSH output intake', async () => { - const { session, deps } = createSession() - const { conn, channel, shell } = createConnection() - await session.establish(conn) - const provider = registeredPlainProvider() - - const { id } = await provider.spawn({ cols: 90, rows: 20 }) - expect(shell).toHaveBeenCalledWith({ cols: 90, rows: 20, term: 'xterm-256color' }) - channel.emit('data', Buffer.from('$ ')) - expect(intake.acceptSshPtyOutputData).toHaveBeenCalledWith( - expect.objectContaining({ id, data: '$ ', rawLength: 2 }) - ) - - channel.emit('exit', 0) - channel.emit('close') - await vi.waitFor(() => - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - expect.stringContaining('plain-'), - 'terminated' - ) - ) - expect(intake.acceptSshPtyOutputExit).toHaveBeenCalledWith( - expect.objectContaining({ id, code: 0 }) - ) - }) - - it('leaves open shells unverifiable when the session is torn down', async () => { - const { session } = createSession() - const { conn, channel } = createConnection() - await session.establish(conn) - const provider = registeredPlainProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - session.detach() - channel.emit('close') - - expect(intake.acceptSshPtyOutputExit).not.toHaveBeenCalled() - expect(intake.closeSshPtyOutputGeneration).toHaveBeenCalledWith( - provider.providerGeneration, - 'connection_lost' - ) - await expect(provider.probePtyLiveness(id)).resolves.toBeNull() - expect(getSshPlainSshMode('target-1')).toBeUndefined() - }) - - it('lets only the current reconnect enter plain SSH mode', async () => { - const { session } = createSession() - const onReady = vi.fn() - session.setOnReady(onReady) - const { conn } = createConnection() - await session.establish(conn) - onReady.mockClear() - vi.mocked(pty.registerSshPtyProvider).mockClear() - - let rejectStale: (error: Error) => void = () => {} - vi.mocked(deployAndLaunchRelay).mockImplementationOnce( - () => new Promise((_resolve, reject) => (rejectStale = reject)) - ) - const stale = session.reconnect(conn) - await vi.waitFor(() => expect(deployAndLaunchRelay).toHaveBeenCalledTimes(2)) - const current = session.reconnect(conn) - await current - rejectStale(runtimeUnavailable()) - await stale - - expect(onReady).toHaveBeenCalledTimes(1) - expect(pty.registerSshPtyProvider).toHaveBeenCalledTimes(1) - expect(session.getPlainSshSession()).not.toBeNull() - session.detach() - }) - - it('still fails the connect on system SSH, which has no shell or SFTP channel', async () => { - const { session } = createSession() - const { conn } = createConnection(true) - - await expect(session.establish(conn)).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(pty.registerSshPtyProvider).not.toHaveBeenCalled() - expect(getSshPlainSshMode('target-1')).toBeUndefined() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts b/src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts deleted file mode 100644 index 0a4d41267dc9..000000000000 --- a/src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// After an app update the new relay answers "not found" for a PTY the previous build's relay still -// runs. While that older relay is live, the session must keep the lease and the pane instead of -// disowning the id, which is what replaced a user's running terminal with an empty shell. -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { acceptOutputDataMock, muxRequestMock, openConsumerSessionMock, pauseAdapterMock } = - vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - pauseAdapterMock: vi.fn() - })) - -vi.mock('./ssh-relay-deploy', () => ({ - deployAndLaunchRelay: vi.fn() -})) - -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -vi.mock('./ssh-channel-multiplexer', () => { - return { - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } - } -}) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('not found'), - isSshPtyIdentityMismatchError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('identity mismatch'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = pauseAdapterMock - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined), - attachForReconnect: vi.fn().mockResolvedValue({}) - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { isReattachHeldByPreviousRelay, startPreviousRelayCensus } = vi.hoisted(() => ({ - isReattachHeldByPreviousRelay: vi.fn(), - startPreviousRelayCensus: vi.fn() -})) -vi.mock('./ssh-previous-relay-terminals', () => ({ - isReattachHeldByPreviousRelay, - startPreviousRelayCensus -})) - -const { attachHeldPtyThroughPreviousRelay } = vi.hoisted(() => ({ - attachHeldPtyThroughPreviousRelay: vi.fn() -})) -vi.mock('../providers/ssh-pty-legacy-relay-delegation', () => ({ - attachHeldPtyThroughPreviousRelay -})) - -const { - getSshPtyProvider, - getPtyIdsForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership -} = await import('../ipc/pty') - -describe('SshRelaySession reattach while a previous relay is live', () => { - beforeEach(() => { - vi.clearAllMocks() - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - attachHeldPtyThroughPreviousRelay.mockResolvedValue(null) - }) - - async function reconnectWithStalePty(holds: boolean) { - const deps = createMockDeps() - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.clearAllMocks() - mockDeploySuccess() - isReattachHeldByPreviousRelay.mockResolvedValue(holds) - // The reconnect reaches only these two members of the provider. - vi.mocked(getSshPtyProvider).mockReturnValue( - Object.assign(Object.create(null), { - attachForReconnect: vi.fn().mockRejectedValue(new Error('PTY "pty-old" not found')), - dispose: vi.fn() - }) - ) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-old']) - await session.reconnect(deps.mockConn) - return deps - } - - it('starts a census of older relays on every deploy', async () => { - await reconnectWithStalePty(false) - expect(startPreviousRelayCensus).toHaveBeenCalledWith( - expect.anything(), - 'target-1', - expect.objectContaining({ platform: 'linux-x64' }) - ) - }) - - it('keeps the lease and the pane when an older relay may run the PTY', async () => { - const { mockStore, mockWindow } = await reconnectWithStalePty(true) - - expect(isReattachHeldByPreviousRelay).toHaveBeenCalledWith( - 'target-1', - expect.objectContaining({ message: 'PTY "pty-old" not found' }) - ) - expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-old') - expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-old') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-old', - 'expired' - ) - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'pty:exit', - expect.objectContaining({ id: 'ssh:target-1@@pty-old' }) - ) - }) - - it('disowns the id as before when no older relay may run it', async () => { - const { mockStore, mockWindow } = await reconnectWithStalePty(false) - - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-old', 'expired') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { - id: 'ssh:target-1@@pty-old', - code: -1, - ptySourceDisowned: true - }) - }) - - it('resumes a held PTY on reconnect through the older relay that serves it', async () => { - attachHeldPtyThroughPreviousRelay.mockResolvedValue({ replay: 'old screen' }) - const { mockStore, mockWindow } = await reconnectWithStalePty(true) - - expect(attachHeldPtyThroughPreviousRelay).toHaveBeenCalledWith( - expect.anything(), - 'ssh:target-1@@pty-old', - undefined - ) - expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-old', 'ssh:target-1') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: 'ssh:target-1@@pty-old', - data: 'old screen' - }) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-old', - 'expired' - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts deleted file mode 100644 index 54ce869a7b96..000000000000 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ /dev/null @@ -1,830 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { randomUUID } from 'node:crypto' -import type * as NodeCrypto from 'node:crypto' -import { SshRelaySession } from './ssh-relay-session' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { - createMockDeps, - mockDeploySuccess, - recordedPtyBindings -} from './ssh-relay-session-test-fixtures' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { SshRemotePtyLease } from '../../shared/ssh-types' - -type MockMuxInstance = { - requestHandlers: Map) => Promise> -} - -const { acceptOutputExitMock, muxRequestMock, openConsumerSessionMock, muxInstancesRaw } = - vi.hoisted(() => ({ - acceptOutputExitMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn( - async ( - _mux: unknown, - options: { clientInstanceId: string; outputFlowControl?: unknown } - ) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - }) - ), - muxInstancesRaw: [] as unknown[] - })) -const muxInstances = muxInstancesRaw as MockMuxInstance[] - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: acceptOutputExitMock, - allocateSshPtyProviderGeneration: vi.fn(() => 17), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('node:crypto', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, randomUUID: vi.fn() } -}) -vi.mock('./ssh-remote-orca-cli', () => ({ - runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - requestHandlers = new Map) => Promise>() - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn( - (method: string, handler: (params: Record) => Promise) => { - this.requestHandlers.set(method, handler) - return () => this.requestHandlers.delete(method) - } - ) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - - constructor() { - muxInstancesRaw.push(this) - } - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn() -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (error: unknown) => String(error).includes('not found'), - isSshPtyIdentityMismatchError: (error: unknown) => String(error).includes('identity mismatch'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { - registerSshPtyProvider, - getSshPtyProvider, - getPtyIdsForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership, - restorePtyIncarnation -} = await import('../ipc/pty') -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') - -const APP_PTY_ID = 'ssh:target-1@@pty-live' -const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function detachedLease() { - return { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached' as const, - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID - } -} - -function emitExitDuringAttach(payload: { - id: string - code: number - incarnationId?: string - providerGeneration?: number - ptyIncarnation?: string -}): void { - const registeredProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onExit: ReturnType - } - const exitHandler = registeredProvider.onExit.mock.calls[0]?.[0] as - | ((exit: typeof payload) => void) - | undefined - queueMicrotask(() => - exitHandler?.({ - providerGeneration: 17, - ptyIncarnation: payload.incarnationId ?? `legacy:${payload.id}`, - ...payload - }) - ) -} - -describe('SshRelaySession reconnect incarnation ordering', () => { - beforeEach(() => { - vi.clearAllMocks() - muxInstances.splice(0) - delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - vi.mocked(randomUUID).mockReset() - vi.mocked(randomUUID).mockReturnValue('00000000-0000-4000-8000-000000000001') - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - it('bounds fifty reattaches to eight workers without slow or failed sibling head-of-line delay', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const ptyIds = Array.from({ length: 50 }, (_, index) => `pty-${index}`) - let active = 0 - let peakActive = 0 - const attempts = new Map() - const mockAttach = vi.fn((id: string) => { - attempts.set(id, (attempts.get(id) ?? 0) + 1) - if (id === 'pty-0') { - return new Promise(() => {}) - } - if (id === 'pty-1') { - return Promise.reject(new Error('isolated attach failure')) - } - active++ - peakActive = Math.max(peakActive, active) - return new Promise((resolve) => { - setTimeout(() => { - active-- - resolve() - }, 100) - }) - }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(ptyIds) - const random = vi.spyOn(Math, 'random').mockReturnValue(0) - vi.useFakeTimers() - - try { - const reconnect = session.reconnect(mockConn) - await vi.advanceTimersByTimeAsync(750) - - expect(setPtyOwnership).toHaveBeenCalledTimes(48) - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - expect(peakActive).toBeLessThanOrEqual(8) - - await vi.advanceTimersByTimeAsync(20_000) - await reconnect - - expect(mockAttach).toHaveBeenCalledTimes(52) - expect(attempts.get('pty-0')).toBe(2) - expect(attempts.get('pty-1')).toBe(2) - expect(session.getState()).toBe('ready') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith( - 'target-1', - expect.arrayContaining(ptyIds.slice(2)) - ) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - expect.any(String), - 'expired' - ) - } finally { - vi.useRealTimers() - random.mockRestore() - } - }) - - it('offers source credit through reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - await session.reconnect(mockConn) - - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 1, - mockConn, - undefined, - undefined, - 'target-1' - ) - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 2, - mockConn, - undefined, - undefined, - 'target-1' - ) - expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) - for (const [, options] of openConsumerSessionMock.mock.calls) { - expect(options.outputFlowControl).toBeDefined() - } - }) - - it('rolls back a timed-out activation that resolves after its replacement commits', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - const timedOutLease = { commit: vi.fn(), rollback: vi.fn() } - const replacementLease = { commit: vi.fn(), rollback: vi.fn() } - let resolveTimedOut!: (result: { - incarnationId: string - sourceActivationLease: typeof timedOutLease - }) => void - const mockAttach = vi - .fn() - .mockReturnValueOnce( - new Promise((resolve) => { - resolveTimedOut = resolve - }) - ) - .mockResolvedValueOnce({ - incarnationId: 'incarnation-replacement', - sourceActivationLease: replacementLease - }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - const random = vi.spyOn(Math, 'random').mockReturnValue(0) - vi.useFakeTimers() - - try { - const reconnect = session.reconnect(mockConn) - await vi.advanceTimersByTimeAsync(11_000) - await reconnect - resolveTimedOut({ - incarnationId: 'incarnation-timed-out', - sourceActivationLease: timedOutLease - }) - await Promise.resolve() - - expect(replacementLease.commit).toHaveBeenCalledOnce() - expect(replacementLease.rollback).not.toHaveBeenCalled() - expect(timedOutLease.rollback).toHaveBeenCalledOnce() - expect(timedOutLease.commit).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - random.mockRestore() - } - }) - - it('keeps the winning reconnect incarnation when a stale health check resolves last', async () => { - const consumerInstanceId = '00000000-0000-4000-8000-000000000000' - const initialIncarnation = '00000000-0000-4000-8000-000000000001' - const winningIncarnation = '00000000-0000-4000-8000-000000000002' - const staleIncarnation = '00000000-0000-4000-8000-000000000003' - let resolveStaleHealthCheck!: (value: unknown) => void - const staleHealthCheck = new Promise((resolve) => { - resolveStaleHealthCheck = resolve - }) - let resolveHomeCalls = 0 - muxRequestMock.mockImplementation((method: string) => { - if (method !== 'session.resolveHome') { - return Promise.resolve([]) - } - resolveHomeCalls += 1 - return resolveHomeCalls === 2 ? staleHealthCheck : Promise.resolve('/') - }) - vi.mocked(randomUUID) - .mockReturnValueOnce(consumerInstanceId) - .mockReturnValueOnce(initialIncarnation) - .mockReturnValueOnce(winningIncarnation) - .mockReturnValue(staleIncarnation) - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const runtime = { - registerOrchestrationCompatibilitySshAttachment: vi.fn( - (_targetId: string, connectionIncarnation: string) => ({ - attachmentId: `attachment-${connectionIncarnation}`, - connectionIncarnation - }) - ), - releaseOrchestrationCompatibilitySshAttachment: vi.fn() - } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - - const staleReconnect = session.reconnect(mockConn) - await vi.waitFor(() => expect(resolveHomeCalls).toBe(2)) - await session.reconnect(mockConn) - expect(session.getState()).toBe('ready') - - resolveStaleHealthCheck('/') - await staleReconnect - - const winningCliHandler = muxInstances[2]?.requestHandlers.get('orca.cli') - expect(winningCliHandler).toBeDefined() - await winningCliHandler?.({ - argv: ['artifacts', 'share', 'report.html'], - cwd: '/srv/repo', - env: {}, - stdin: '

Remote

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

Remote

', - artifactInput: { - sourceKey: '/srv/repo/report.html', - fileName: 'report.html', - contentType: 'text/html' - } - }) - ) - expect(randomUUID).toHaveBeenCalledTimes(3) - }) - - it('restores and persists exact incarnation proof from reconnect attach', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const incarnationId = 'incarnation-reconnect' - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID, - incarnationId - }) - expect(runtime.onPtySpawned).not.toHaveBeenCalled() - expect(recordedPtyBindings(mockStore)).toContainEqual({ - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID, - ptyId: APP_PTY_ID, - incarnationId, - mayReviveRetiredSurface: false, - origin: 'relay_reattach' - }) - // Into the pane's home partition: a `local` copy shadowed it at the next startup (STA-9544). - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), 'ssh:target-1') - expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(mockStore.markSshRemotePtyLeasesAttachedAsync).mock.invocationCallOrder[0]! - ) - }) - - it.each([ - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'surface' }, - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'surface' }, - { - relay: 'legacy', - incarnationId: undefined, - tombstonePartition: 'local', - retiredBy: 'surface' - }, - { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'host', retiredBy: 'surface' }, - // A closed tab whose pane is in no tab: the close record is the backstop. - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'close' }, - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'close' } - ])( - 'suppresses a $tombstonePartition-partition $retiredBy retirement from a $relay relay', - async ({ incarnationId, tombstonePartition, retiredBy }) => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const attachForReconnect = vi.fn().mockResolvedValue({ - ...(incarnationId ? { incarnationId } : {}), - replay: 'retired-output' - }) - const shutdown = vi.fn().mockRejectedValue(new Error('transport lost')) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect, - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - const worktreeId = 'repo-1::/worktree' - const tabId = 'tab-retired' - const leafId = INCARNATION_LEAF_ID - const paneKey = `${tabId}:${leafId}` - const leases: SshRemotePtyLease[] = [ - { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached', - worktreeId, - tabId, - createdAt: 1, - updatedAt: 1, - leafId - } - ] - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue(leases) - const sessionWithTombstone: ReturnType = { - ...getDefaultWorkspaceSession(), - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: APP_PTY_ID } - } - }, - terminalSurfaceTombstonesByPaneKey: { - [paneKey]: { - worktreeId, - parentTabId: tabId, - leafId, - ptyId: APP_PTY_ID, - incarnationId: 'inc-1', - retiredAt: 1 - } - } - } - const closedTab: ReturnType = { - ...getDefaultWorkspaceSession(), - closedTerminalTabTombstonesByTabId: { [tabId]: { closedAt: Date.now(), worktreeId } } - } - vi.mocked(mockStore.getWorkspaceSession).mockImplementation((hostId) => - (hostId ? 'host' : 'local') !== tombstonePartition - ? getDefaultWorkspaceSession() - : retiredBy === 'close' - ? closedTab - : sessionWithTombstone - ) - const runtime = { registerPty: vi.fn(), onPtySpawned: vi.fn() } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - } finally { - warn.mockRestore() - } - - expect(runtime.registerPty).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - APP_PTY_ID, - 'expired' - ) - if (incarnationId) { - expect(mockStore.recordSshRemotePtyKillIntent).toHaveBeenCalledWith( - 'target-1', - 'pty-live', - { requestedAt: expect.any(Number), incarnationId, attempts: 0 } - ) - expect(shutdown).toHaveBeenCalledWith(APP_PTY_ID, { - immediate: true, - expectedIncarnationId: incarnationId - }) - } else { - expect(mockStore.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - } - expect(clearProviderPtyState).toHaveBeenCalledWith(APP_PTY_ID) - expect(deletePtyOwnership).toHaveBeenCalledWith(APP_PTY_ID) - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:replay', { - id: APP_PTY_ID, - data: 'retired-output' - }) - } - ) - - it.each([ - { - case: 'stale PTY id', - tombstonePtyId: 'ssh:target-1@@pty-old', - tombstoneIncarnationId: 'incarnation-old' - }, - { - case: 'stale incarnation of the reused PTY id', - tombstonePtyId: APP_PTY_ID, - tombstoneIncarnationId: 'incarnation-old' - }, - { - case: 'same identity at the pane lease old location', - tombstonePtyId: APP_PTY_ID, - tombstoneIncarnationId: 'incarnation-live' - } - ])('reattaches a moved pane despite a $case tombstone', async (tombstone) => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const worktreeId = 'repo-1::/worktree' - const oldTabId = 'tab-old' - const movedTabId = 'tab-moved' - const incarnationId = 'incarnation-live' - const shutdown = vi.fn() - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId, replay: 'live-output' }), - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached', - worktreeId, - tabId: oldTabId, - leafId: INCARNATION_LEAF_ID, - createdAt: 1, - updatedAt: 1 - } - ]) - const movedSession: ReturnType = { - ...getDefaultWorkspaceSession(), - terminalLayoutsByTabId: { - [movedTabId]: { - root: { type: 'leaf', leafId: INCARNATION_LEAF_ID }, - activeLeafId: INCARNATION_LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [INCARNATION_LEAF_ID]: APP_PTY_ID } - } - }, - terminalPtyIncarnationsByPaneKey: { - [`${movedTabId}:${INCARNATION_LEAF_ID}`]: incarnationId - }, - terminalSurfaceTombstonesByPaneKey: { - [`${oldTabId}:${INCARNATION_LEAF_ID}`]: { - worktreeId, - parentTabId: oldTabId, - leafId: INCARNATION_LEAF_ID, - ptyId: tombstone.tombstonePtyId, - incarnationId: tombstone.tombstoneIncarnationId, - retiredAt: 1 - } - } - } - vi.mocked(mockStore.getWorkspaceSession).mockImplementation((hostId) => - hostId ? getDefaultWorkspaceSession() : movedSession - ) - const runtime = { registerPty: vi.fn(), onPtySpawned: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, worktreeId, 'target-1', { - tabId: movedTabId, - leafId: INCARNATION_LEAF_ID, - incarnationId - }) - expect(recordedPtyBindings(mockStore)).toContainEqual( - expect.objectContaining({ tabId: movedTabId, ptyId: APP_PTY_ID, incarnationId }) - ) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - APP_PTY_ID, - 'expired' - ) - expect(mockStore.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-live' - ]) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: APP_PTY_ID, - data: 'live-output' - }) - }) - - it('does not restore a PTY whose matching exit shares the attach reply batch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const incarnationId = 'incarnation-exited-during-attach' - const runtime = { - acceptPtyIncarnationForExit: vi.fn(), - onPtyExit: vi.fn(), - onPtySpawned: vi.fn(), - registerPty: vi.fn() - } - const sourceActivationLease = { commit: vi.fn(), rollback: vi.fn() } - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockImplementation(async () => { - emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId }) - emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId: 'incarnation-stale' }) - return { incarnationId, replay: 'dead-output', sourceActivationLease } - }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(acceptOutputExitMock).toHaveBeenCalledWith({ - id: APP_PTY_ID, - code: 0, - providerGeneration: 17, - ptyIncarnation: incarnationId - }) - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(runtime.acceptPtyIncarnationForExit).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) - expect(runtime.registerPty).not.toHaveBeenCalled() - expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(mockStore.persistPtyBinding).not.toHaveBeenCalled() - expect(sourceActivationLease.rollback).toHaveBeenCalledOnce() - expect(sourceActivationLease.commit).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'terminated' - ) - expect( - vi - .mocked(mockWindow.webContents.send) - .mock.calls.some(([channel]) => channel === 'pty:replay') - ).toBe(false) - }) - - it('ignores an older incarnation exit while reconnecting a reused PTY id', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const currentIncarnationId = 'incarnation-current' - const runtime = { - acceptPtyIncarnationForExit: vi.fn(), - onPtyExit: vi.fn(), - onPtySpawned: vi.fn(), - registerPty: vi.fn() - } - const sourceActivationLease = { commit: vi.fn(), rollback: vi.fn() } - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockImplementation(async () => { - emitExitDuringAttach({ - id: APP_PTY_ID, - code: 0, - incarnationId: 'incarnation-old' - }) - return { incarnationId: currentIncarnationId, replay: 'live-output', sourceActivationLease } - }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(runtime.acceptPtyIncarnationForExit).not.toHaveBeenCalled() - expect(sourceActivationLease.commit).toHaveBeenCalledOnce() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID, - incarnationId: currentIncarnationId - }) - expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'ssh:target-1') - expect(recordedPtyBindings(mockStore)).toContainEqual( - expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) - ) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: APP_PTY_ID, - data: 'live-output' - }) - }) - - it('keeps the PTY detached when incarnation backfill persistence fails', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const incarnationId = 'incarnation-reconnect' - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - vi.mocked(mockStore.persistPtyBinding).mockImplementationOnce(() => { - throw new Error('disk full') - }) - const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await expect(session.establish(mockConn)).resolves.toBeUndefined() - - expect(runtime.registerPty).not.toHaveBeenCalled() - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(restorePtyIncarnation).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:replay', expect.anything()) - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('Leaving PTY pty-live detached for target-1') - ) - consoleWarn.mockRestore() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts deleted file mode 100644 index ed0136f9110a..000000000000 --- a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts +++ /dev/null @@ -1,680 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR, - PTY_CONSUMER_OWNER_HELD_SELF_ERROR, - PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, - PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR -} from '../../shared/pty-consumer-session' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps } from './ssh-relay-session-test-fixtures' -import { getSshPtyConsumerRecovery } from './ssh-pty-consumer-recovery' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}), - applySshPtySourceCancellationProof: vi.fn(), - applySshPtySourceRecoveryCancellationProof: vi.fn() -})) - -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) - -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn().mockReturnValue(false), - isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const { clearPtyOwnershipForConnection, unregisterSshPtyProvider } = await import('../ipc/pty') - -describe('SshRelaySession consumer recovery durability', () => { - beforeEach(() => { - vi.clearAllMocks() - muxRequestMock.mockResolvedValue([]) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - }, - resumed: false - })) - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - }) - - it('holds establish open until the consumer recovery write is durable', async () => { - const deps = createMockDeps() - let settleWrite!: () => void - let signalWriteStarted!: () => void - const writeStarted = new Promise((resolve) => { - signalWriteStarted = resolve - }) - vi.mocked(deps.mockStore.upsertSshPtyConsumerRecovery).mockImplementation(() => { - signalWriteStarted() - return new Promise((resolve) => { - settleWrite = resolve - }) - }) - - const session = new SshRelaySession( - 'durability-barrier-target', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - let established = false - const establishing = session.establish(deps.mockConn).then(() => { - established = true - }) - - await writeStarted - // Why a macrotask, not a microtask count: establish() has several awaits after the write starts, - // so only yielding past the whole microtask queue proves the write is the thing blocking it. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(established).toBe(false) - - settleWrite() - await establishing - expect(established).toBe(true) - session.dispose() - }) - - it('retries a pending incumbent publication before recovering its persisted lease', async () => { - vi.useFakeTimers() - try { - const targetId = 'target-owner-publication-pending' - const deps = createMockDeps() - vi.mocked(deps.mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - openConsumerSessionMock.mockRejectedValueOnce( - Object.assign(new Error('Owner grant publication is still pending'), { - code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR - }) - ) - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - - const establishing = session.establish(deps.mockConn) - await vi.advanceTimersByTimeAsync(25) - await establishing - - expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) - expect(openConsumerSessionMock.mock.calls[0]?.[1]).toMatchObject({ - clientInstanceId: 'persisted-client', - resume: { ownerGeneration: 1, ownerLease: 'persisted-owner' } - }) - session.dispose() - } finally { - vi.useRealTimers() - } - }) - - it('preserves persisted recovery while a superseding transport is still live', async () => { - vi.useFakeTimers() - try { - const targetId = 'target-owner-generation-superseded' - const deps = createMockDeps() - vi.mocked(deps.mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - const superseded = Object.assign(new Error('Owner recovery generation was superseded'), { - code: PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR - }) - openConsumerSessionMock.mockRejectedValue(superseded) - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - - const failed = expect(session.establish(deps.mockConn)).rejects.toBe(superseded) - await vi.advanceTimersByTimeAsync(3_000) - await failed - - expect(openConsumerSessionMock.mock.calls.length).toBeGreaterThan(1) - expect(deps.mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - } finally { - vi.useRealTimers() - } - }) - - it('keeps destructive disposal pending until consumer recovery is removed', async () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId: 'target-disposal-durability', - clientInstanceId: 'client-disposal-durability', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'owner-lease' - }) - let settleRemoval!: () => void - vi.mocked(mockStore.removeSshPtyConsumerRecovery).mockImplementation( - () => - new Promise((resolve) => { - settleRemoval = resolve - }) - ) - const session = new SshRelaySession( - 'target-disposal-durability', - getMainWindow, - mockStore, - mockPortForward - ) - let completed = false - - const disposal = session.disposeAndPersist().then(() => { - completed = true - }) - await Promise.resolve() - - expect(session.getState()).toBe('disposed') - expect(completed).toBe(false) - expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith( - 'target-disposal-durability', - 'terminated' - ) - - settleRemoval() - await disposal - expect(completed).toBe(true) - }) - - it('reports an attached owner as terminal instead of a lost relay', async () => { - const targetId = 'target-owner-held-attached' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onRelayLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onRelayLost) - await session.establish(mockConn) - - openConsumerSessionMock.mockRejectedValueOnce( - Object.assign(new Error('PTY session owner is held by an attached connection'), { - code: PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR - }) - ) - await session.reconnect(mockConn) - - // Why not relay-lost: the relay answered and refused on purpose, so redeploy backoff would spend - // its whole budget on a link that is working. - expect(onRelayLost).not.toHaveBeenCalled() - expect(onTerminal).toHaveBeenCalledWith( - targetId, - expect.objectContaining({ name: 'SshOwnerAdmissionBlockedError' }) - ) - expect(onTerminal.mock.calls[0]?.[1]?.message).toContain('owns the remote terminals') - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) - session.dispose() - }) - - it("routes this client's own attached connection to relay-lost recovery, not to a parked error", async () => { - const targetId = 'target-owner-held-self' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onRelayLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onRelayLost) - await session.establish(mockConn) - - // The incumbent is this app's own half-open connection, which the relay has not yet observed - // closing — the ordinary single-app case, since only SshRelaySession ever requests session-owner. - openConsumerSessionMock.mockRejectedValue( - Object.assign( - new Error("PTY session owner is held by this client's own earlier connection"), - { - code: PTY_CONSUMER_OWNER_HELD_SELF_ERROR - } - ) - ) - await session.reconnect(mockConn) - - // Why not terminal: parking here clears backoff and never retries, so the user stays locked out - // of their own session until they restart the app. Backoff is what lets keepalive reap the zombie. - expect(onTerminal).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalled() - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - }) - - it('recovers a forgotten owner record in one request without deleting recovery identity', async () => { - const targetId = 'target-forgotten-owner-record' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'forgotten-owner' - }) - openConsumerSessionMock.mockImplementationOnce(async () => ({ - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 4, - ownerGeneration: 9, - ownerLease: 'fresh-owner' - }, - resumed: false - })) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { - id: 'pty-1' - } as unknown as never) - - await session.establish(mockConn) - - expect(openConsumerSessionMock).toHaveBeenCalledTimes(1) - expect(openConsumerSessionMock.mock.calls[0]?.[1]).toMatchObject({ - resume: { ownerGeneration: 1, ownerLease: 'forgotten-owner' } - }) - // Why both: the fresh claim voids checkpoints taken under the old lease, but the recovery identity - // is what lets this client keep resuming the target at all — a refusal must never cost it. - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.size).toBe(0) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( - expect.objectContaining({ - targetId, - clientInstanceId: 'persisted-client', - ownerGeneration: 9, - ownerLease: 'fresh-owner' - }) - ) - session.dispose() - }) - - it('keeps checkpoints when a resumed claim comes back', async () => { - const targetId = 'target-resumed-owner-record' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - openConsumerSessionMock.mockImplementationOnce(async () => ({ - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 4, - ownerGeneration: 2, - ownerLease: 'persisted-owner' - }, - resumed: true - })) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { - id: 'pty-1' - } as unknown as never) - - await session.establish(mockConn) - - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - }) - - it('leaves recovery state alone when a newer owner already claimed the target record', async () => { - const targetId = 'target-stale-owner-loser' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'stale-owner' - }) - const winner = { - mode: 'negotiated' as const, - clientInstanceId: 'persisted-client', - clientGeneration: 2, - ownerGeneration: 5, - ownerLease: 'winner-owner' - } - openConsumerSessionMock.mockImplementationOnce(async () => { - // Why inside the open: the record is target-scoped, so the winner can land while this attempt is - // still waiting on its own resume, leaving this attempt's `previousOwner` snapshot stale. - const record = getSshPtyConsumerRecovery(targetId)! - record.owner = winner - record.checkpointsByAppPtyId.set('pty-1', { id: 'pty-1' } as unknown as never) - return { - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 3, - ownerGeneration: 1, - ownerLease: 'loser-owner' - }, - resumed: false - } - }) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - }) - - it('mutates no recovery state after a local attempt loses its authority', async () => { - const targetId = 'target-owner-attempt-superseded' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - let signalOpenStarted!: () => void - const openStarted = new Promise((resolve) => { - signalOpenStarted = resolve - }) - let finishOpen!: (value: unknown) => void - openConsumerSessionMock.mockImplementationOnce(() => { - signalOpenStarted() - return new Promise((resolve) => { - finishOpen = resolve - }) - }) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { - id: 'pty-1' - } as unknown as never) - - const superseded = session.establish(mockConn) - const failed = expect(superseded).rejects.toThrow('Session disposed during establish') - await openStarted - // Why detach and not disposal: disposal legitimately removes the record, which would hide whether - // the attempt that lost its authority mutated anything on its way out. - await session.detachAndPersist() - finishOpen({ - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'superseded-owner' - }, - resumed: false - }) - await failed - - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) - expect(mockStore.upsertSshPtyConsumerRecovery).not.toHaveBeenCalledWith( - expect.objectContaining({ ownerLease: 'superseded-owner' }) - ) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - }) - - it('does not remember a consumer opened after establish was disposed', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - let signalOpenStarted!: () => void - const openStarted = new Promise((resolve) => { - signalOpenStarted = resolve - }) - let finishOpen!: (value: unknown) => void - openConsumerSessionMock.mockImplementationOnce(() => { - signalOpenStarted() - return new Promise((resolve) => { - finishOpen = resolve - }) - }) - const session = new SshRelaySession( - 'target-open-disposal', - getMainWindow, - mockStore, - mockPortForward - ) - - const establishing = session.establish(mockConn) - const failed = expect(establishing).rejects.toThrow('Session disposed during establish') - await openStarted - await session.disposeAndPersist() - finishOpen({ - state: { - mode: 'negotiated', - clientInstanceId: 'late-client', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'late-owner' - }, - resumed: false - }) - await failed - - expect(mockStore.upsertSshPtyConsumerRecovery).not.toHaveBeenCalled() - }) - - it('detaches for shutdown in memory without a per-session durable write', async () => { - const targetId = 'target-shutdown-detach' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() - - session.beginShutdownDetach() - session.beginShutdownDetach() - - // Why in-memory only: the committed quit path runs the final store flush immediately after this, - // and a durable write here would race that flush rather than be captured by it. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledExactlyOnceWith( - targetId, - 'detached' - ) - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() - // Why the recovery record survives: 'detached' means this app let go, not that the remote shell - // died — the identity is what lets the next launch reattach those still-running PTYs. - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(getSshPtyConsumerRecovery(targetId)?.detached).toBe(true) - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBeTypeOf('string') - }) - - it('keeps ordinary detach durability reporting unchanged after a shutdown detach exists', async () => { - const targetId = 'target-shutdown-vs-ordinary-detach' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( - new Error('lease write failed') - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - // Why still rejecting: only the shutdown path delegates durability; an ordinary detach must keep - // surfacing a failed write to its caller. - await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') - expect(mockStore.markSshRemotePtyLeasesForShutdown).not.toHaveBeenCalled() - }) - - it('upgrades a pending detach to a full disposal', async () => { - const targetId = 'target-teardown-upgrade' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - let settleDetachPersistence!: () => void - let settleDisposalPersistence!: () => void - vi.mocked(mockStore.markSshRemotePtyLeasesAsync) - .mockImplementationOnce( - () => - new Promise((resolve) => { - settleDetachPersistence = resolve - }) - ) - .mockImplementationOnce( - () => - new Promise((resolve) => { - settleDisposalPersistence = resolve - }) - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - let detachCompleted = false - const detach = session.detachAndPersist().then(() => { - detachCompleted = true - }) - const disposal = session.disposeAndPersist() - - // Why: dispose supersedes the in-flight detach, so the destructive half must still run. - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'terminated') - expect(getSshPtyConsumerRecovery(targetId)).toBeUndefined() - // Why: 'shutdown' teardown, not detach's 'connection_lost' — PTY ownership is released for good. - expect(clearPtyOwnershipForConnection).toHaveBeenCalledWith(targetId) - - settleDetachPersistence() - await Promise.resolve() - await Promise.resolve() - expect(detachCompleted).toBe(false) - settleDisposalPersistence() - await Promise.all([detach, disposal]) - - // Why: the reverse order is not an upgrade — a detach after disposal must not re-open ownership. - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() - await session.detachAndPersist() - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() - }) - - it('re-issues only the lease write after a rejected detach persistence', async () => { - const targetId = 'target-detach-write-retry' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( - new Error('lease write failed') - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(clearPtyOwnershipForConnection).mockClear() - - await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') - - const teardownCalls = vi.mocked(unregisterSshPtyProvider).mock.calls.length - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() - await session.detachAndPersist() - - // Why: the retry re-issues the write only — re-running provider teardown would tear down - // whatever a replacement session has already registered for this target. - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'detached') - expect(unregisterSshPtyProvider).toHaveBeenCalledTimes(teardownCalls) - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - }) - - it('still upgrades to disposal after a rejected detach persistence', async () => { - const targetId = 'target-detach-write-failure-disposal' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( - new Error('lease write failed') - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') - await session.disposeAndPersist() - - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'terminated') - expect(getSshPtyConsumerRecovery(targetId)).toBeUndefined() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts deleted file mode 100644 index f4600bb23e6c..000000000000 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ /dev/null @@ -1,839 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { - acceptOutputDataMock, - acceptOutputExitMock, - muxRequestMock, - onNotificationByMethodMock, - openConsumerSessionMock, - muxDisposeMock, - ptyProviderDisposeMock, - sourceAckCleanupMock, - sourceCancellationCleanupMock, - attachForReconnectMock, - ptyDataHandlerRef, - ptyExitHandlerRef -} = vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - acceptOutputExitMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - onNotificationByMethodMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - muxDisposeMock: vi.fn(), - ptyProviderDisposeMock: vi.fn(), - sourceAckCleanupMock: vi.fn(), - sourceCancellationCleanupMock: vi.fn(), - attachForReconnectMock: vi.fn().mockResolvedValue({}), - ptyDataHandlerRef: { current: undefined as undefined | ((payload: unknown) => void) }, - ptyExitHandlerRef: { current: undefined as undefined | ((payload: unknown) => void) } -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: acceptOutputExitMock, - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => sourceAckCleanupMock), - installSshPtySourceCancellationPublisher: vi.fn(() => sourceCancellationCleanupMock), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = onNotificationByMethodMock.mockImplementation(() => () => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = muxDisposeMock - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn().mockReturnValue(false), - isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockImplementation((handler) => { - ptyDataHandlerRef.current = handler - return () => {} - }) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockImplementation((handler) => { - ptyExitHandlerRef.current = handler - return () => {} - }) - attachForReconnect = attachForReconnectMock - setPtyDeliveryPauseAdapter = vi.fn() - dispose = ptyProviderDisposeMock - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - isCurrentPtyExit: vi.fn(() => true), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { - clearProviderPtyState, - clearPtyOwnershipForConnection, - deletePtyOwnership, - getSshPtyProvider, - getPtyIdsForConnection, - registerSshPtyProvider, - setPtyOwnership, - unregisterSshPtyProvider -} = await import('../ipc/pty') -const { closeSshPtyOutputGeneration, getSshPtyAcceptedSourceCheckpoints } = - await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceCancellationProof } = await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceRecoveryCancellationProof } = - await import('../ipc/ssh-pty-output-intake-registry') - -describe('SshRelaySession recovery race fencing', () => { - beforeEach(() => { - vi.clearAllMocks() - ptyDataHandlerRef.current = undefined - ptyExitHandlerRef.current = undefined - attachForReconnectMock.mockResolvedValue({}) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - vi.mocked(applySshPtySourceCancellationProof).mockReturnValue(true) - vi.mocked(applySshPtySourceRecoveryCancellationProof).mockReturnValue(true) - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - function emitSourceFrame( - args: { - targetId: string - token: string - clientGeneration: number - ownerGeneration: number - sourceStartSu: number - sourceEndSu: number - data?: string - }, - sink = ptyDataHandlerRef.current - ): void { - sink?.({ - id: `ssh:${args.targetId}@@pty-1`, - data: args.data ?? 'late', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: args.sourceEndSu - args.sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `${args.token}:${args.sourceStartSu}:${args.sourceEndSu}`, - clientGeneration: args.clientGeneration, - ownerGeneration: args.ownerGeneration, - deliveryToken: args.token, - sourceStartSu: args.sourceStartSu, - sourceEndSu: args.sourceEndSu - } - }) - } - - function pendingRecovery(recoveryEndSu: number) { - return { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu - } - } - - function completeRecovery(params: Record): void { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.(params) - } - - async function prepareRecovery(targetId: string): Promise<{ - session: SshRelaySession - deps: ReturnType - }> { - let generation = 0 - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: ++generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: options.resume !== undefined - })) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: `ssh:${targetId}@@pty-1`, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([`ssh:${targetId}@@pty-1`]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - return { session, deps } - } - - it('keeps source output contiguous while the recovered pane binding waits for disk', async () => { - const targetId = 'recovery-binding-disk-wait' - const { session, deps } = await prepareRecovery(targetId) - const binding = Promise.withResolvers() - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId, - ptyId: 'pty-1', - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - state: 'detached', - createdAt: 1, - updatedAt: 1 - } - ]) - vi.mocked(deps.mockStore.persistPtyBinding).mockReturnValue(binding.promise) - const emit = (sourceStartSu: number, sink = ptyDataHandlerRef.current): void => - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu, - sourceEndSu: sourceStartSu + 4 - }, - sink - ) - let recoverySink: typeof ptyDataHandlerRef.current - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease: { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: (sink: (payload: unknown) => void) => { - recoverySink = sink - emit(4, sink) - completeRecovery({ id: 'pty-1', ...pendingRecovery(8) }) - return recoveryActivationLease - } - } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(deps.mockStore.persistPtyBinding).toHaveBeenCalledOnce()) - emit(8, recoverySink) - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - binding.resolve(true) - await reconnect - emit(12) - - expect( - acceptOutputDataMock.mock.calls.map(([payload]) => payload.source.sourceStartSu) - ).toEqual([4, 8, 12]) - expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() - }) - - it('publishes held recovery data before an exact exit without waiting for completion', async () => { - const targetId = 'exit-with-complete-private-body' - const { session, deps } = await prepareRecovery(targetId) - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 4, - sourceEndSu: 8, - data: 'held' - }, - sink - ) - return recoveryActivationLease - }) - } - attachForReconnectMock.mockImplementation(async () => { - ptyExitHandlerRef.current?.({ - id: `ssh:${targetId}@@pty-1`, - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - incarnationId: 'incarnation-1' - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease - } - }) - - await session.reconnect(deps.mockConn) - - expect(acceptOutputDataMock).toHaveBeenCalledWith(expect.objectContaining({ data: 'held' })) - expect(acceptOutputExitMock).toHaveBeenCalledOnce() - expect(acceptOutputDataMock.mock.invocationCallOrder[0]).toBeLessThan( - acceptOutputExitMock.mock.invocationCallOrder[0]! - ) - expect(sourceActivationLease.transferToRecovery).toHaveBeenCalledOnce() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() - expect(recoveryActivationLease.retire).not.toHaveBeenCalled() - expect(muxRequestMock).not.toHaveBeenCalledWith('pty.cancelDelivery', expect.anything()) - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - }) - - it('settles exact cancellation before publishing an exit with incomplete recovery data', async () => { - const targetId = 'exit-with-incomplete-private-body' - let settleCancellation: ((proof: unknown) => void) | undefined - muxRequestMock.mockImplementation((method) => - method === 'pty.cancelDelivery' - ? new Promise((resolve) => { - settleCancellation = resolve - }) - : Promise.resolve([]) - ) - const { session, deps } = await prepareRecovery(targetId) - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 4, - sourceEndSu: 6, - data: 'partial' - }, - sink - ) - return recoveryActivationLease - }) - } - attachForReconnectMock.mockImplementation(async () => { - ptyExitHandlerRef.current?.({ - id: `ssh:${targetId}@@pty-1`, - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - incarnationId: 'incarnation-1' - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease - } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token' - }) - ) - expect(acceptOutputExitMock).not.toHaveBeenCalled() - - settleCancellation?.({ canceled: true, sentEndSu: 6, creditedEndSu: 4 }) - await reconnect - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(acceptOutputExitMock).toHaveBeenCalledOnce() - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - expect(recoveryActivationLease.retire).toHaveBeenCalledOnce() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(applySshPtySourceRecoveryCancellationProof).toHaveBeenCalledOnce() - }) - - it('awaits provisional cancellation proof before publishing an exact recovery exit', async () => { - const targetId = 'exit-before-recovery-identity' - let settleRollback: ((settled: boolean) => void) | undefined - const { session, deps } = await prepareRecovery(targetId) - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn( - () => - new Promise((resolve) => { - settleRollback = resolve - }) - ), - transferToRecovery: vi.fn() - } - attachForReconnectMock.mockImplementation(async () => { - ptyExitHandlerRef.current?.({ - id: `ssh:${targetId}@@pty-1`, - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - incarnationId: 'incarnation-1' - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' }, - sourceActivationLease - } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(sourceActivationLease.rollback).toHaveBeenCalledOnce()) - expect(acceptOutputExitMock).not.toHaveBeenCalled() - - settleRollback?.(true) - await reconnect - - expect(acceptOutputExitMock).toHaveBeenCalledOnce() - expect(sourceActivationLease.transferToRecovery).not.toHaveBeenCalled() - expect(sourceActivationLease.commit).not.toHaveBeenCalled() - }) - - it('retains the empty recovery end as the first post-activation source anchor', async () => { - const targetId = 'empty-recovery-gap' - const { session, deps } = await prepareRecovery(targetId) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - completeRecovery({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(4) - } - }) - await session.reconnect(deps.mockConn) - const closeCount = vi.mocked(closeSshPtyOutputGeneration).mock.calls.length - const muxDisposeCount = muxDisposeMock.mock.calls.length - - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 5, - sourceEndSu: 8 - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount) - expect(muxDisposeMock).toHaveBeenCalledTimes(muxDisposeCount) - await vi.waitFor(() => expect(attachForReconnectMock).toHaveBeenCalledTimes(2)) - }) - - it('drops late frames from a token after its cancellation proof is validated', async () => { - const targetId = 'late-after-cancel' - muxRequestMock.mockImplementation(async (method) => - method === 'pty.cancelDelivery' ? { canceled: true, sentEndSu: 8, creditedEndSu: 4 } : [] - ) - const { session, deps } = await prepareRecovery(targetId) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 5, - sourceEndSu: 8 - }) - completeRecovery({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8) - } - }) - await session.reconnect(deps.mockConn) - const closeCount = vi.mocked(closeSshPtyOutputGeneration).mock.calls.length - - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 8, - sourceEndSu: 12 - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount) - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(targetId, 'pty-1', 'detached') - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - }) - - it('rejects cancellation proof below capacity-rejected and later private frames', async () => { - const targetId = 'capacity-watermark' - muxRequestMock.mockImplementation(async (method) => - method === 'pty.cancelDelivery' ? { canceled: true, sentEndSu: 8, creditedEndSu: 4 } : [] - ) - const { session, deps } = await prepareRecovery(targetId) - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - for (const [data, sourceStartSu] of [ - ['x'.repeat(2 * 1024 * 1024 + 1), 4], - ['later', 8] - ] as const) { - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu, - sourceEndSu: sourceStartSu + 4, - data - }, - sink - ) - } - return recoveryActivationLease - }) - } - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(12), - sourceActivationLease - }) - - await session.reconnect(deps.mockConn) - - expect(muxRequestMock).toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'new-token' }) - ) - expect(vi.mocked(closeSshPtyOutputGeneration).mock.calls).toContainEqual([ - 23, - 'ssh_source_recovery_cancellation_failed' - ]) - expect(applySshPtySourceRecoveryCancellationProof).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - expect(recoveryActivationLease.retire).toHaveBeenCalledOnce() - }) - - it.each([ - ['publication', undefined], - ['proof', { canceled: true, sentEndSu: 8, creditedEndSu: 4 }], - ['negative-end', { canceled: true, sentEndSu: -1, creditedEndSu: 4 }], - ['credited-ahead', { canceled: true, sentEndSu: 8, creditedEndSu: 9 }], - ['checkpoint-mismatch', { canceled: true, sentEndSu: 8, creditedEndSu: 3 }], - ['sent-before-private-end', { canceled: true, sentEndSu: 7, creditedEndSu: 4 }] - ] as const)( - 'closes one provider generation when recovery cancellation %s rejects', - async (failure, cancellationResult) => { - const targetId = `cancel-${failure}-failure` - muxRequestMock.mockImplementation(async (method) => { - if (method !== 'pty.cancelDelivery') { - return [] - } - if (failure === 'publication') { - throw new Error('cancel publication failed') - } - return cancellationResult - }) - if (failure === 'proof') { - vi.mocked(applySshPtySourceRecoveryCancellationProof).mockImplementation(() => { - throw new Error('cancel proof rejected') - }) - } - const { session, deps } = await prepareRecovery(targetId) - const onRelayLost = vi.fn() - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const activationLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn(() => recoveryActivationLease) - } - session.setOnRelayLost(onRelayLost) - let cleanupCountsBeforeFailure: - | { - generation: number - mux: number - provider: number - ack: number - cancellation: number - unregister: number - } - | undefined - attachForReconnectMock.mockImplementation(async () => { - cleanupCountsBeforeFailure = { - generation: vi.mocked(closeSshPtyOutputGeneration).mock.calls.length, - mux: muxDisposeMock.mock.calls.length, - provider: ptyProviderDisposeMock.mock.calls.length, - ack: sourceAckCleanupMock.mock.calls.length, - cancellation: sourceCancellationCleanupMock.mock.calls.length, - unregister: vi.mocked(unregisterSshPtyProvider).mock.calls.length - } - queueMicrotask(() => { - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 5, - sourceEndSu: 8 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease: activationLease - } - }) - - await session.reconnect(deps.mockConn) - - expect(cleanupCountsBeforeFailure).toBeDefined() - const before = cleanupCountsBeforeFailure! - expect(vi.mocked(closeSshPtyOutputGeneration).mock.calls).toHaveLength(before.generation + 1) - expect(closeSshPtyOutputGeneration).toHaveBeenLastCalledWith( - 23, - 'ssh_source_recovery_cancellation_failed' - ) - expect(muxDisposeMock.mock.calls).toHaveLength(before.mux + 1) - expect(muxDisposeMock).toHaveBeenLastCalledWith('connection_lost') - expect(ptyProviderDisposeMock.mock.calls).toHaveLength(before.provider + 1) - expect(sourceAckCleanupMock.mock.calls).toHaveLength(before.ack + 1) - expect(sourceCancellationCleanupMock.mock.calls).toHaveLength(before.cancellation + 1) - expect(vi.mocked(unregisterSshPtyProvider).mock.calls).toHaveLength(before.unregister + 1) - expect(unregisterSshPtyProvider).toHaveBeenLastCalledWith(targetId) - expect(activationLease.transferToRecovery).toHaveBeenCalledOnce() - expect(activationLease.commit).not.toHaveBeenCalled() - expect(activationLease.rollback).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - expect(recoveryActivationLease.retire).toHaveBeenCalledOnce() - expect(onRelayLost).toHaveBeenCalledOnce() - expect(session.getState()).toBe('reconnecting') - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'pty:exit', - expect.anything() - ) - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - expect(vi.mocked(closeSshPtyOutputGeneration).mock.calls).not.toContainEqual([ - 99, - expect.anything() - ]) - expect(unregisterSshPtyProvider).not.toHaveBeenCalledWith('unrelated-target') - } - ) - - it('overwrites the migration checkpoint with the post-recovery one for the same pty', async () => { - const targetId = 'post-recovery-checkpoint-rekey' - const { session, deps } = await prepareRecovery(targetId) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 4, - sourceEndSu: 8 - }) - completeRecovery({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - }) - return { incarnationId: 'incarnation-1', sourceRecovery: pendingRecovery(4) } - }) - await session.reconnect(deps.mockConn) - expect(acceptOutputDataMock).toHaveBeenCalledOnce() - - // The pre-migration identity is gone from the intake, so only the checkpoint - // recorded after recovery can answer the next reconnect. - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } - }) - await session.reconnect(deps.mockConn) - - expect(attachForReconnectMock).toHaveBeenCalledTimes(2) - expect(attachForReconnectMock.mock.calls.at(-1)?.[2]).toMatchObject({ - status: 'checkpoint', - deliveryToken: 'new-token', - acceptedSourceEndSu: 8 - }) - }) - - it('keeps a stale overlapping recovery from canceling or mutating its replacement', async () => { - const targetId = 'overlapping-recovery' - const { session, deps } = await prepareRecovery(targetId) - const staleRecoveryLease = { commit: vi.fn(), retire: vi.fn() } - const replacementRecoveryLease = { commit: vi.fn(), retire: vi.fn() } - const staleLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn(() => staleRecoveryLease) - } - const replacementLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn(() => replacementRecoveryLease) - } - attachForReconnectMock.mockImplementation(async () => { - const ownerGeneration = openConsumerSessionMock.mock.calls.length - if (ownerGeneration === 3) { - queueMicrotask(() => { - completeRecovery({ - id: 'pty-1', - clientGeneration: 3, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'replacement-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - }) - } - return { - incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: ownerGeneration, - ownerGeneration, - ptyIncarnation: 'incarnation-1', - deliveryToken: ownerGeneration === 2 ? 'stale-token' : 'replacement-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }, - sourceActivationLease: ownerGeneration === 2 ? staleLease : replacementLease - } - }) - - const staleReconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(attachForReconnectMock).toHaveBeenCalledTimes(1)) - await Promise.resolve() - const replacementReconnect = session.reconnect(deps.mockConn) - await Promise.all([staleReconnect, replacementReconnect]) - - const recoveryRequests = attachForReconnectMock.mock.calls.map((call) => call[2]) - expect(recoveryRequests).toHaveLength(2) - expect(recoveryRequests[1]).toMatchObject({ - status: 'checkpoint', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - }) - expect(muxRequestMock.mock.calls.filter(([method]) => method === 'pty.cancelDelivery')).toEqual( - [] - ) - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith(targetId, [ - 'pty-1' - ]) - expect(setPtyOwnership).toHaveBeenCalledTimes(1) - expect(staleLease.transferToRecovery).toHaveBeenCalledOnce() - expect(staleLease.commit).not.toHaveBeenCalled() - expect(staleLease.rollback).not.toHaveBeenCalled() - expect(staleRecoveryLease.commit).not.toHaveBeenCalled() - expect(staleRecoveryLease.retire).toHaveBeenCalledOnce() - expect(replacementLease.transferToRecovery).toHaveBeenCalledOnce() - expect(replacementLease.commit).not.toHaveBeenCalled() - expect(replacementLease.rollback).not.toHaveBeenCalled() - expect(replacementRecoveryLease.commit).toHaveBeenCalledOnce() - expect(replacementRecoveryLease.retire).not.toHaveBeenCalled() - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - expect(session.getState()).toBe('ready') - }) -}) diff --git a/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts b/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts deleted file mode 100644 index e0f1572d4d3d..000000000000 --- a/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' -import type { SshPtyProvider } from '../providers/ssh-pty-provider' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { SshPtyConsumerSessionState } from './ssh-pty-consumer-session' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps } from './ssh-relay-session-test-fixtures' - -const { acceptOutputDataMock, getSshPtyProviderMock } = vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - getSshPtyProviderMock: vi.fn() -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', async (importOriginal) => { - const original = (await importOriginal()) as object - return { ...original, acceptSshPtyOutputData: acceptOutputDataMock } -}) - -vi.mock('../ipc/pty', async (importOriginal) => { - const original = (await importOriginal()) as object - return { ...original, getSshPtyProvider: getSshPtyProviderMock } -}) - -type SshPtyDataPayload = Parameters[0] - -type RejectedDeliverySession = { - mux: SshChannelMultiplexer | null - activePtyProviderGeneration: number | null - ptyConsumerSessionState: SshPtyConsumerSessionState | null - acceptPtyData: (payload: SshPtyDataPayload) => Promise - reattachKnownPty: (args: { - ptyId: string - activeLeaseByPtyId: Map - expectedIdentityByPtyId: Map - attachedLeaseIds: Set - targetedDeliveryRecovery?: 'confirm-existing' | 'fresh-activation' - }) => Promise - reattachRejectedPty: ( - relayPtyId: string, - mux: SshChannelMultiplexer, - providerGeneration: number, - targetedDeliveryRecovery: 'confirm-existing' | 'fresh-activation' - ) => Promise - sourceRecoveryRequest: (appPtyId: string) => Promise< - | { - status: 'checkpoint' - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string - acceptedSourceEndSu: number - } - | undefined - > - rejectedPtyRecoveryAttempts: Map - sourceIdentityByRelayPtyId: Map - retireExitedPty: (payload: { - id: string - code: number - providerGeneration: number - ptyIncarnation: string - }) => void -} - -function source(overrides: Partial> = {}) { - return { - relayPtyId: 'pty-bad', - spanId: 'token-bad:0:4', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-bad', - sourceStartSu: 0, - sourceEndSu: 4, - ...overrides - } -} - -function rejectedPayload(overrides: Partial = {}): SshPtyDataPayload { - return { - id: 'ssh:target-1@@pty-bad', - data: 'rejected', - providerGeneration: 23, - ptyIncarnation: 'incarnation-bad', - source: source(), - sourceRejected: true, - ...overrides - } -} - -function prepareSession() { - const deps = createMockDeps() - const mux = { - isDisposed: vi.fn(() => false), - dispose: vi.fn() - } as unknown as SshChannelMultiplexer - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - const internals = session as unknown as RejectedDeliverySession - internals.mux = mux - internals.activePtyProviderGeneration = 23 - internals.ptyConsumerSessionState = { - mode: 'negotiated', - clientInstanceId: 'client-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'owner-lease', - outputFlowControl: { version: 1, windowSu: 64 } - } - return { deps, internals, mux, session } -} - -describe('SshRelaySession rejected PTY delivery recovery', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('quarantines source-less output queued before a flow-control grant', async () => { - const { internals, mux } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData(rejectedPayload({ source: undefined, sourceRejected: undefined })) - - expect(warn).toHaveBeenCalledWith( - '[ssh-relay-session] Rejected PTY delivery', - expect.objectContaining({ - sourceState: 'source-missing', - recoveryAction: 'quarantine-legacy-frame' - }) - ) - expect(reattach).not.toHaveBeenCalled() - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it.each([ - ['client', 9, 1], - ['owner', 1, 9] - ] as const)( - 'retires a superseded %s generation without disposing the mux', - async (_generation, clientGeneration, ownerGeneration) => { - const { internals, mux } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData( - rejectedPayload({ source: source({ clientGeneration, ownerGeneration }) }) - ) - - expect(reattach).toHaveBeenCalledWith('pty-bad', mux, 23, 'confirm-existing') - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(mux.dispose).not.toHaveBeenCalled() - } - ) - - it('logs malformed source metadata and reattaches from the exact outer PTY identity', async () => { - const { internals } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData( - rejectedPayload({ source: undefined, sourceMalformed: true, sourceRejected: undefined }) - ) - - expect(warn).toHaveBeenCalledWith( - '[ssh-relay-session] Rejected PTY delivery', - expect.objectContaining({ - ptyId: 'ssh:target-1@@pty-bad', - providerGeneration: 23, - expectedClientGeneration: 1, - expectedOwnerGeneration: 1, - sourceState: 'source-malformed', - recoveryAction: 'retire-and-reattach-delivery' - }) - ) - expect(reattach).toHaveBeenCalledWith('pty-bad', expect.anything(), 23, 'confirm-existing') - expect(acceptOutputDataMock).not.toHaveBeenCalled() - }) - - it('reattaches only the rejected PTY while a healthy sibling keeps delivering', async () => { - const { deps, internals, mux } = prepareSession() - const provider = {} as SshPtyProvider - getSshPtyProviderMock.mockReturnValue(provider) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId: 'target-1', - ptyId: 'pty-bad', - state: 'detached', - createdAt: 1, - updatedAt: 1 - }, - { - targetId: 'target-1', - ptyId: 'pty-healthy', - state: 'attached', - createdAt: 1, - updatedAt: 1 - } - ]) - const reattachKnownPty = vi.fn( - async (args: Parameters[0]) => { - args.attachedLeaseIds.add(args.ptyId) - } - ) - internals.reattachKnownPty = reattachKnownPty - - await internals.acceptPtyData(rejectedPayload({ rejectedSourceRecovery: 'fresh-activation' })) - await vi.waitFor(() => expect(reattachKnownPty).toHaveBeenCalledOnce()) - await internals.acceptPtyData( - rejectedPayload({ - id: 'ssh:target-1@@pty-healthy', - data: 'healthy', - ptyIncarnation: 'incarnation-healthy', - source: source({ - relayPtyId: 'pty-healthy', - spanId: 'token-healthy:0:7', - deliveryToken: 'token-healthy', - sourceEndSu: 7 - }), - sourceRejected: undefined - }) - ) - - const recovery = reattachKnownPty.mock.calls[0]?.[0] - expect(recovery?.ptyId).toBe('pty-bad') - expect(Array.from(recovery?.activeLeaseByPtyId.keys() ?? [])).toEqual(['pty-bad']) - expect(Array.from(recovery?.expectedIdentityByPtyId.keys() ?? [])).toEqual([]) - expect(recovery?.targetedDeliveryRecovery).toBe('fresh-activation') - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-bad' - ]) - expect(acceptOutputDataMock).toHaveBeenCalledOnce() - expect(acceptOutputDataMock).toHaveBeenCalledWith( - expect.objectContaining({ id: 'ssh:target-1@@pty-healthy', data: 'healthy' }) - ) - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it('reconnects instead of canceling an unprovable malformed delivery', async () => { - const { internals, mux } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData( - rejectedPayload({ - source: undefined, - sourceMalformed: true, - sourceRejected: undefined, - rejectedSourceRecovery: 'reconnect-channel' - }) - ) - - expect(mux.dispose).toHaveBeenCalledWith('connection_lost') - expect(reattach).not.toHaveBeenCalled() - }) - - it('opens a fresh activation after exact rejected-delivery cancellation', async () => { - const { deps, internals, mux } = prepareSession() - internals.sourceIdentityByRelayPtyId.set('pty-bad', { - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-old', - nextSourceSu: 4 - }) - const commit = vi.fn() - const attachForReconnect = vi.fn(async () => ({ - incarnationId: 'incarnation-bad', - sourceActivation: { - status: 'pending' as const, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-fresh', - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }, - sourceActivationLease: { commit, rollback: vi.fn(async () => true) } - })) - getSshPtyProviderMock.mockReturnValue({ attachForReconnect } as unknown as SshPtyProvider) - - await expect( - internals.reattachRejectedPty('pty-bad', mux, 23, 'fresh-activation') - ).resolves.toBe(true) - - expect(attachForReconnect).toHaveBeenCalledWith('pty-bad') - expect(commit).toHaveBeenCalledOnce() - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-bad' - ]) - await internals.acceptPtyData( - rejectedPayload({ - data: 'fresh', - sourceRejected: undefined, - source: source({ deliveryToken: 'token-fresh' }) - }) - ) - expect(acceptOutputDataMock).toHaveBeenCalledWith(expect.objectContaining({ data: 'fresh' })) - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it('accepts an exact existing activation as stale-frame confirmation', async () => { - const { internals, mux } = prepareSession() - const checkpoint = { - status: 'checkpoint' as const, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-current', - acceptedSourceEndSu: 7 - } - internals.sourceRecoveryRequest = vi.fn(async () => checkpoint) - const commit = vi.fn() - const attachForReconnect = vi.fn(async () => ({ - incarnationId: 'incarnation-bad', - sourceActivation: { - status: 'pending' as const, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-current', - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }, - sourceActivationLease: { commit, rollback: vi.fn(async () => true) } - })) - getSshPtyProviderMock.mockReturnValue({ attachForReconnect } as unknown as SshPtyProvider) - - await expect( - internals.reattachRejectedPty('pty-bad', mux, 23, 'confirm-existing') - ).resolves.toBe(true) - - expect(attachForReconnect).toHaveBeenCalledWith('pty-bad', undefined, checkpoint) - expect(commit).toHaveBeenCalledOnce() - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it('forgets rejected-delivery recovery history when the PTY exits', () => { - const { internals } = prepareSession() - internals.rejectedPtyRecoveryAttempts.set('ssh:target-1@@pty-bad', {}) - - internals.retireExitedPty({ - id: 'ssh:target-1@@pty-bad', - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-bad' - }) - - expect(internals.rejectedPtyRecoveryAttempts).toHaveLength(0) - }) - - // Why a channel drop rather than a terminal relay error: a terminal error clears the reconnect - // backoff and rotates provider authority, aborting every fs and git request on the target, so one - // PTY's undeliverable output would strand the whole connection in manual recovery. - it('bounds failed targeted recovery and escalates to a recoverable relay reconnect', async () => { - const { internals, mux, session } = prepareSession() - const reattach = vi.fn().mockResolvedValue(false) - internals.reattachRejectedPty = reattach - getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) - const onTerminalError = vi.fn() - session.setOnTerminalRelayError(onTerminalError) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData(rejectedPayload()) - await vi.waitFor(() => expect(mux.dispose).toHaveBeenCalledOnce(), { timeout: 2000 }) - - expect(reattach).toHaveBeenCalledTimes(2) - expect(reattach.mock.calls).toEqual([ - ['pty-bad', mux, 23, 'confirm-existing'], - ['pty-bad', mux, 23, 'confirm-existing'] - ]) - expect(mux.dispose).toHaveBeenCalledWith('connection_lost') - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('PTY pty-bad delivery recovery exhausted') - ) - expect(onTerminalError).not.toHaveBeenCalled() - expect(acceptOutputDataMock).not.toHaveBeenCalled() - }) - - it('stops without an error when the rejected PTY exited during recovery', async () => { - const { internals, mux, session } = prepareSession() - const reattach = vi.fn().mockResolvedValue(false) - internals.reattachRejectedPty = reattach - // Why: reattachKnownPty resolves without claiming the lease when the PTY exits mid-attach, so a - // plain "not recovered" is indistinguishable from a failure until liveness is checked. - getSshPtyProviderMock.mockReturnValue({ hasPty: () => false } as unknown as SshPtyProvider) - const onTerminalError = vi.fn() - session.setOnTerminalRelayError(onTerminalError) - - await internals.acceptPtyData(rejectedPayload()) - await vi.waitFor(() => expect(reattach).toHaveBeenCalledOnce()) - await new Promise((resolve) => setTimeout(resolve, 400)) - - expect(reattach).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(mux.dispose).not.toHaveBeenCalled() - }) - - // Why bounded: the bulk reconnect path caps the identical attach work at 8, and a relay that - // starts rejecting across every PTY at once would otherwise open one attach round trip per PTY. - it('caps concurrent targeted reattaches and coalesces repeats for one PTY', async () => { - const { internals } = prepareSession() - getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const release: (() => void)[] = [] - let active = 0 - let peak = 0 - const reattach = vi.fn(async () => { - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => release.push(resolve)) - active-- - return true - }) - internals.reattachRejectedPty = reattach - - for (let index = 0; index < 20; index++) { - const relayPtyId = `pty-${index}` - await internals.acceptPtyData( - rejectedPayload({ - id: `ssh:target-1@@${relayPtyId}`, - source: source({ relayPtyId, deliveryToken: `token-${index}` }) - }) - ) - // Why twice: a repeat for a PTY already recovering must not consume a second slot. - await internals.acceptPtyData( - rejectedPayload({ - id: `ssh:target-1@@${relayPtyId}`, - source: source({ relayPtyId, deliveryToken: `token-${index}-repeat` }) - }) - ) - } - - expect(peak).toBe(8) - expect(reattach).toHaveBeenCalledTimes(8) - for (const resolve of release.splice(0)) { - resolve() - } - await vi.waitFor(() => expect(reattach).toHaveBeenCalledTimes(16)) - expect(peak).toBe(8) - for (const resolve of release.splice(0)) { - resolve() - } - }) - - it('does not let accepted frames refill the recovery budget indefinitely', async () => { - const { internals, mux, session } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) - const onTerminalError = vi.fn() - session.setOnTerminalRelayError(onTerminalError) - - // Why alternating, with a fresh bad token each round: every rejection retires its own delivery, - // so a flapping PTY only keeps asking for recovery by moving onto new ones, and the accepted - // frame in between is what used to clear the budget outright. Each reattach here succeeds, so - // the consecutive budget is cleared legitimately too — only the per-generation ceiling can stop - // it. - for (let round = 0; round < 40; round++) { - await internals.acceptPtyData( - rejectedPayload({ - source: source({ - spanId: `token-bad-${round}:0:4`, - deliveryToken: `token-bad-${round}`, - clientGeneration: 9 - }) - }) - ) - await internals.acceptPtyData( - rejectedPayload({ - sourceRejected: undefined, - source: source({ - spanId: `token-good:${round * 4}:${round * 4 + 4}`, - deliveryToken: 'token-good', - sourceStartSu: round * 4, - sourceEndSu: round * 4 + 4 - }) - }) - ) - await Promise.resolve() - } - await vi.waitFor(() => expect(mux.dispose).toHaveBeenCalledOnce(), { timeout: 2000 }) - - expect(onTerminalError).not.toHaveBeenCalled() - expect(acceptOutputDataMock).toHaveBeenCalledTimes(40) - expect(reattach.mock.calls.length).toBeLessThanOrEqual(12) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-relay-loss.test.ts b/src/main/ssh/ssh-relay-session-relay-loss.test.ts deleted file mode 100644 index 5053426131d3..000000000000 --- a/src/main/ssh/ssh-relay-session-relay-loss.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -// #11953: the grace-time notify is the last thing establish()/reconnect() do -// before latching 'ready', and it can dispose the mux synchronously (writer -// admission cap / throwing transport). Latching 'ready' there left the status -// bar on "connected" with fs/pty/git providers bound to a dead mux and no -// relay-loss watcher, so nothing ever scheduled a reconnect. - -const { muxRequestMock, openConsumerSessionMock, registeredPtyProvider } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - registeredPtyProvider: { dispose: vi.fn(), attachForReconnect: vi.fn() } -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -// Mirrors the real multiplexer contract: dispose() latches, and a failed write -// during notify disposes the mux synchronously via handleProtocolError. -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - private disposed = false - private disposeReason: string | null = null - private disposeHandlers: ((reason: string) => void)[] = [] - /** Set by the test to kill the channel from inside notify(). */ - failNotifyMethod: string | null = null - notify = vi.fn((method: string) => { - if (method === this.failNotifyMethod) { - this.dispose('connection_lost') - } - }) - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn((handler: (reason: string) => void) => { - if (this.disposed) { - handler(this.disposeReason ?? 'shutdown') - return () => {} - } - this.disposeHandlers.push(handler) - return () => { - const idx = this.disposeHandlers.indexOf(handler) - if (idx !== -1) { - this.disposeHandlers.splice(idx, 1) - } - } - }) - dispose = vi.fn((reason = 'shutdown') => { - if (this.disposed) { - return - } - this.disposed = true - this.disposeReason = reason - for (const handler of this.disposeHandlers.splice(0)) { - handler(reason) - } - }) - isDisposed = vi.fn(() => this.disposed) - } -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: () => false, - isSshPtyIdentityMismatchError: () => false, - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue(registeredPtyProvider), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = - await import('../providers/ssh-filesystem-dispatch') -const { getPtyIdsForConnection } = await import('../ipc/pty') - -describe('SshRelaySession relay loss during setup', () => { - /** Armed by a test so the *next* mux dies inside its grace-time notify. */ - let armGraceTimeFailure = false - let armProviderRegistrationFailure = false - - beforeEach(() => { - vi.clearAllMocks() - armGraceTimeFailure = false - armProviderRegistrationFailure = false - muxRequestMock.mockReset() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - registeredPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - mockDeploySuccess() - }) - - function createSession(): { - session: SshRelaySession - onRelayLost: ReturnType - onReady: ReturnType - mockStore: ReturnType['mockStore'] - } { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onRelayLost = vi.fn() - const onReady = vi.fn() - session.setOnRelayLost(onRelayLost) - session.setOnReady(onReady) - // Arm once the mux exists; every attempt issues requests before the notify. - muxRequestMock.mockImplementation(async (method: string) => { - const mux = session.getMux() as unknown as { - failNotifyMethod: string | null - dispose: (reason: string) => void - } | null - if (mux && armGraceTimeFailure) { - mux.failNotifyMethod = SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD - } - if (mux && armProviderRegistrationFailure && method === 'git.listWorktrees') { - mux.dispose('connection_lost') - throw new Error('SSH connection lost, reconnecting...') - } - return [] - }) - return { session, onRelayLost, onReady, mockStore } - } - - it('fails establish instead of reporting ready on a dead channel', async () => { - const { session, onReady } = createSession() - armGraceTimeFailure = true - - await expect(session.establish({} as SshConnection)).rejects.toThrow( - 'Relay connection lost during establish' - ) - - expect(registerSshFilesystemProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) - - it('routes a dead channel during reconnect into relay-loss recovery', async () => { - const { session, onRelayLost, onReady } = createSession() - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - onReady.mockClear() - - armGraceTimeFailure = true - await session.reconnect({} as SshConnection) - - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalledTimes(1) - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) - - it('routes a mux that dies during provider registration into relay-loss recovery', async () => { - const { session, onRelayLost, onReady, mockStore } = createSession() - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - onReady.mockClear() - - vi.mocked(mockStore.getRepos).mockReturnValue([ - { connectionId: 'target-1', path: '/repo' } as ReturnType[number] - ]) - armProviderRegistrationFailure = true - - await session.reconnect({} as SshConnection) - - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalledTimes(1) - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) - - // #13548 follow-up: cancelling the in-flight port scan emits rpc.cancel on the control - // lane, which a saturated writer turns into mux.dispose('connection_lost'). That fires - // during our own teardown, so it must not be reported as a lost relay. - it.each([ - ['detach', (session: SshRelaySession) => session.beginShutdownDetach()], - ['dispose', (session: SshRelaySession) => session.dispose()] - ])( - 'does not report relay loss when the port-scan cancel kills the mux on %s', - async (_path, teardown) => { - const { session, onRelayLost } = createSession() - // Mirrors SshChannelMultiplexer.request: an in-flight request whose signal aborts - // emits rpc.cancel, and the mock mux dies on that notify like a saturated writer. - muxRequestMock.mockImplementation( - async (method: string, _params?: unknown, options?: { signal?: AbortSignal }) => { - if (method !== 'ports.detect') { - return [] - } - const mux = session.getMux() as unknown as { - failNotifyMethod: string | null - notify: (method: string, params?: unknown) => void - } - mux.failNotifyMethod = 'rpc.cancel' - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - mux.notify('rpc.cancel', { id: 1 }) - reject(new Error('cancelled')) - }, - { once: true } - ) - }) - } - ) - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - expect(muxRequestMock).toHaveBeenCalledWith('ports.detect', undefined, expect.anything()) - const mux = session.getMux() as unknown as { notify: ReturnType } - - teardown(session) - - expect(mux.notify).toHaveBeenCalledWith('rpc.cancel', { id: 1 }) - expect(onRelayLost).not.toHaveBeenCalled() - } - ) - - // #11953: reattachKnownPtys swallows every per-PTY failure, so a mux killed by the - // reattach burst itself never reaches the catch — the post-reattach gate has to notice. - it('routes a mux that dies during PTY reattach into relay-loss recovery', async () => { - const { session, onRelayLost, onReady } = createSession() - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - onReady.mockClear() - - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - registeredPtyProvider.attachForReconnect.mockImplementation(async () => { - const mux = session.getMux() as unknown as { dispose: (reason: string) => void } | null - mux?.dispose('connection_lost') - throw new Error('SSH connection lost, reconnecting...') - }) - - await session.reconnect({} as SshConnection) - - expect(registeredPtyProvider.attachForReconnect).toHaveBeenCalled() - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalledTimes(1) - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) -}) diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts deleted file mode 100644 index 9ce9410ecd4e..000000000000 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' -import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' -import { RelayEndpointUnresponsiveError } from './ssh-relay-endpoint-incumbent' -import type { BrowserWindow } from 'electron' - -const { filesystemProviderConstructorMock } = vi.hoisted(() => ({ - filesystemProviderConstructorMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ - deployAndLaunchRelay: vi.fn() -})) - -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: vi.fn(async (_mux, options) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) -})) - -vi.mock('./ssh-channel-multiplexer', () => { - return { - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - request = vi.fn().mockResolvedValue([]) - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } - } -}) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('not found'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - constructor(...args: unknown[]) { - filesystemProviderConstructorMock(...args) - } - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined) - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') - -function createMockDeps(): { - mockConn: SshConnection - mockStore: Store - mockPortForward: SshPortForwardManager - getMainWindow: () => BrowserWindow | null -} { - const mockConn = {} as SshConnection - const mockStore = { - getRepos: vi.fn().mockReturnValue([]), - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn(), - removeSshPtyConsumerRecovery: vi.fn(), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - reconcileSshRemotePtyLeasesForTarget: vi.fn(), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn(), - markSshRemotePtyLeasesAsync: vi.fn(), - markSshRemotePtyLeasesAttachedAsync: vi.fn(), - getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]), - pruneExpiredSshRemotePtyKillIntents: vi.fn(), - recordSshRemotePtyKillIntent: vi.fn(), - clearSshRemotePtyKillIntent: vi.fn(), - noteSshRemotePtyKillReplayAttempt: vi.fn() - } as unknown as Store - const mockPortForward = { - removeAllForwards: vi.fn() - } as unknown as SshPortForwardManager - const mockWindow = { - isDestroyed: (): boolean => false, - webContents: { send: vi.fn() } - } as unknown as BrowserWindow - const getMainWindow = vi.fn().mockReturnValue(mockWindow) as unknown as () => BrowserWindow | null - return { mockConn, mockStore, mockPortForward, getMainWindow } -} - -function mockDeploySuccess(): void { - const mockTransport = { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - } - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: mockTransport, - platform: 'linux-x64' - }) -} - -describe('SshRelaySession terminal relay error (RelayVersionMismatchError)', () => { - beforeEach(() => { - vi.clearAllMocks() - filesystemProviderConstructorMock.mockReset() - mockDeploySuccess() - }) - - it('omits the SFTP folder factory while retaining raw transfer on system SSH', async () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = { - usesSystemSshTransport: vi.fn(() => true) - } as unknown as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(filesystemProviderConstructorMock).toHaveBeenCalledWith( - 'target-1', - expect.anything(), - undefined, - expect.objectContaining({ downloadFile: expect.any(Function) }), - undefined - ) - }) - - it('fires onTerminalRelayError on initial establish() and rethrows', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onLost) - - const mismatchErr = new RelayVersionMismatchError( - '0.1.0+aaa', - '0.1.0+bbb', - '[relay-connect] Handshake mismatch...' - ) - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(mismatchErr) - - await expect(session.establish(mockConn)).rejects.toBe(mismatchErr) - expect(onTerminal).toHaveBeenCalledTimes(1) - expect(onTerminal).toHaveBeenCalledWith('target-1', mismatchErr) - expect(onLost).not.toHaveBeenCalled() - expect(session.getState()).toBe('idle') - }) - - it('does NOT fire onTerminalRelayError on a generic establish failure', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - session.setOnTerminalRelayError(onTerminal) - - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(new Error('boom')) - - await expect(session.establish(mockConn)).rejects.toThrow('boom') - expect(onTerminal).not.toHaveBeenCalled() - expect(session.getState()).toBe('idle') - }) - - it('routes RelayEndpointUnresponsiveError on reconnect() to onRelayLost, not the terminal path', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onLost) - - await session.establish(mockConn) - const silent = new RelayEndpointUnresponsiveError({ - sockPath: '/home/u/.orca-remote/relay-x/relay.sock', - verdict: 'live', - evidence: 'accepted-connection', - socketPresent: true, - holders: [], - holdersEnumerable: false - }) - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(silent) - - await session.reconnect(mockConn) - expect(onTerminal).not.toHaveBeenCalled() - expect(onLost).toHaveBeenCalledWith('target-1') - }) - - it('fires onTerminalRelayError on reconnect() when deploy throws RelayVersionMismatchError', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - session.setOnTerminalRelayError(onTerminal) - - await session.establish(mockConn) - expect(session.getState()).toBe('ready') - - const mismatchErr = new RelayVersionMismatchError('0.1.0+old', '0.1.0+new', '') - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(mismatchErr) - - await session.reconnect(mockConn) - expect(onTerminal).toHaveBeenCalledTimes(1) - expect(onTerminal).toHaveBeenCalledWith('target-1', mismatchErr) - expect(session.getState()).toBe('idle') - }) -}) diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts deleted file mode 100644 index 0e9e0c03579d..000000000000 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { vi, type Mock } from 'vitest' -import type { BrowserWindow } from 'electron' -import type { SshConnection } from './ssh-connection' -import type { PersistPtyBindingArgs } from '../persistence/loading-store/pty-binding-persistence' -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' -import { deployAndLaunchRelay } from './ssh-relay-deploy' - -type SshRelaySessionTestDeps = { - mockConn: SshConnection - mockStore: Store - mockPortForward: SshPortForwardManager - getMainWindow: Mock<() => BrowserWindow | null> - mockWindow: BrowserWindow -} - -const persistedBindings = new WeakMap() - -export function recordedPtyBindings(store: Store): readonly PersistPtyBindingArgs[] { - return persistedBindings.get(store) ?? [] -} - -export function createMockDeps(): SshRelaySessionTestDeps { - const bindings: PersistPtyBindingArgs[] = [] - const mockConn = {} as SshConnection - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown. - const mockStore = { - getRepos: vi.fn().mockReturnValue([]), - getSshTarget: vi.fn().mockReturnValue(undefined), - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn(), - removeSshPtyConsumerRecovery: vi.fn(), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - reconcileSshRemotePtyLeasesForTarget: vi.fn(), - getWorkspaceSession: vi.fn(), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn(), - markSshRemotePtyLeasesAsync: vi.fn(), - markSshRemotePtyLeasesForShutdown: vi.fn(), - markSshRemotePtyLeasesAttachedAsync: vi.fn(), - getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]), - pruneExpiredSshRemotePtyKillIntents: vi.fn(), - recordSshRemotePtyKillIntent: vi.fn(), - clearSshRemotePtyKillIntent: vi.fn(), - noteSshRemotePtyKillReplayAttempt: vi.fn(), - persistPtyBinding: vi.fn(async (input: Parameters[0]) => { - const binding = typeof input === 'function' ? input() : input - if (!binding) { - return false - } - bindings.push(binding) - return true - }) - } as unknown as Store - persistedBindings.set(mockStore, bindings) - const mockPortForward = { - removeAllForwards: vi.fn() - } as unknown as SshPortForwardManager - const mockWindow = { - isDestroyed: () => false, - // Why: the port scanner visibility-gates its ticks; a visible mock window - // keeps establish-path tests exercising the scan-on-ready behavior. - isVisible: () => true, - isMinimized: () => false, - webContents: { send: vi.fn() } - } as unknown as BrowserWindow - const getMainWindow = vi.fn().mockReturnValue(mockWindow) - return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } -} - -export function mockDeploySuccess(): void { - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, - platform: 'linux-x64' - }) -} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts deleted file mode 100644 index 589523e9a448..000000000000 --- a/src/main/ssh/ssh-relay-session.test.ts +++ /dev/null @@ -1,935 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - AGENT_HOOK_INSTALL_PLUGINS_METHOD -} from '../../shared/agent-hook-relay' -import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { acceptOutputDataMock, muxRequestMock, openConsumerSessionMock, pauseAdapterMock } = - vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - pauseAdapterMock: vi.fn() - })) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-previous-relay-terminals', () => import('./ssh-previous-relay-census-test-double')) - -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -vi.mock('./ssh-channel-multiplexer', () => { - return { - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } - } -}) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('not found'), - isSshPtyIdentityMismatchError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('identity mismatch'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = pauseAdapterMock - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined), - attachForReconnect: vi.fn().mockResolvedValue({}) - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const { execCommand } = await import('./ssh-relay-deploy-helpers') -const { getRemoteHostPlatform } = await import('./ssh-remote-platform') -const { - registerSshPtyProvider, - unregisterSshPtyProvider, - getPtyIdsForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership -} = await import('../ipc/pty') -const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = - await import('../providers/ssh-filesystem-dispatch') -const { registerSshGitProvider, unregisterSshGitProvider } = - await import('../providers/ssh-git-dispatch') - -describe('SshRelaySession', () => { - beforeEach(() => { - vi.clearAllMocks() - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - it('hands each PTY data event exactly once to the bounded main intake', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { - id: string - data: string - providerGeneration: number - ptyIncarnation: string - }) => void - - onData({ - id: 'ssh-pty-1', - data: 'ssh output', - providerGeneration: 41, - ptyIncarnation: 'incarnation-1' - }) - - expect(acceptOutputDataMock).toHaveBeenCalledTimes(1) - expect(acceptOutputDataMock).toHaveBeenCalledWith({ - id: 'ssh-pty-1', - data: 'ssh output', - providerGeneration: 41, - ptyIncarnation: 'incarnation-1', - rawLength: 'ssh output'.length, - transformed: false - }) - expect(runtime.onPtyData).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything()) - }) - - it('starts in idle state', () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - expect(session.getState()).toBe('idle') - expect(session.getMux()).toBeNull() - }) - - it('transitions idle → deploying → ready on establish', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(session.getState()).toBe('ready') - expect(session.getMux()).not.toBeNull() - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(registerSshFilesystemProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) - }) - - it('rechecks OpenCode preparation from scans and aborts it when the relay session disconnects', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const prepareOpenCodeRuntime = vi.fn().mockResolvedValue(undefined) - vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - prepareOpenCodeRuntime - }) - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - await session.requestAiVaultSessionList({}) - await session.requestSessionSearch('sessionSearch.search', {}) - expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2) - const signal = prepareOpenCodeRuntime.mock.calls[0][0] - expect(signal.aborted).toBe(false) - await session.dispose() - expect(signal.aborted).toBe(true) - await expect(session.requestAiVaultSessionList({})).rejects.toThrow('not ready') - expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2) - }) - - it('continues provider registration when the relay managed-hook request fails', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { agents: ['codex'] } - } - if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) { - throw new Error('runtime unavailable') - } - return { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish({} as SshConnection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith( - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - expect.anything() - ) - ) - - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect( - muxRequestMock.mock.calls.some(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - ).toBe(true) - }) - - it('suppresses expected managed-hook teardown errors during disconnect', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { agents: ['codex'] } - } - if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) { - throw Object.assign(new Error('request disposed'), { code: 'DISPOSED' }) - } - return { ok: true } - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - try { - await session.establish({} as SshConnection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith( - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - expect.anything() - ) - ) - - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(warn.mock.calls.flat().join(' ')).not.toContain('relay managed hook install failed') - } finally { - warn.mockRestore() - } - }) - - it('does not run POSIX managed hook installers on Windows remotes', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = { - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection - vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, - platform: 'win32-x64', - hostPlatform: getRemoteHostPlatform('win32-x64'), - remoteHome: 'C:/Users/me', - remoteRelayDir: 'C:/Users/me/.orca-remote/relay-v1', - nodePath: 'C:/Program Files/nodejs/node.exe', - sockPath: '\\\\.\\pipe\\orca-relay-123' - }) - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect( - muxRequestMock.mock.calls.some( - ([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ) - ).toBe(false) - expect( - muxRequestMock.mock.calls.some(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - ).toBe(true) - }) - - it('does not register providers if dispose wins during initial plugin sync', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - let resolvePluginInstall!: () => void - muxRequestMock.mockImplementation(async (method: string) => { - if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) { - return new Promise((resolve) => { - resolvePluginInstall = () => resolve({ ok: true }) - }) - } - return { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = {} as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - const establish = session.establish(mockConn) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith( - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - expect.anything() - ) - ) - session.dispose() - resolvePluginInstall() - - await expect(establish).rejects.toThrow('Session disposed during establish') - expect(registerSshPtyProvider).not.toHaveBeenCalled() - expect(registerSshFilesystemProvider).not.toHaveBeenCalled() - expect(registerSshGitProvider).not.toHaveBeenCalled() - }) - - it('rejects establish when not idle', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - await expect(session.establish(mockConn)).rejects.toThrow('Cannot establish relay session') - }) - - it('reverts to idle on establish failure', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(new Error('deploy failed')) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await expect(session.establish(mockConn)).rejects.toThrow('deploy failed') - expect(session.getState()).toBe('idle') - }) - - it('reconnect tears down old providers and registers new ones', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - const oldMux = session.getMux() - - vi.clearAllMocks() - mockDeploySuccess() - - await session.reconnect(mockConn) - - expect(session.getState()).toBe('ready') - expect(session.getMux()).not.toBe(oldMux) - expect(unregisterSshPtyProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshGitProvider).toHaveBeenCalledWith('target-1') - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - }) - - it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = { - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection - vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, - platform: 'win32-x64', - hostPlatform: getRemoteHostPlatform('win32-x64'), - remoteHome: 'C:/Users/me', - remoteRelayDir: 'C:/Users/me/.orca-remote/relay-v1', - nodePath: 'C:/Program Files/nodejs/node.exe', - sockPath: '\\\\.\\pipe\\orca-relay-123' - }) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(execCommand).toHaveBeenCalledTimes(2) - expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe') - expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false }) - expect(mockConn.writeFile).toHaveBeenCalledWith( - 'C:/Users/me/.orca-relay/bin/orca-launcher.cs', - expect.stringContaining('ProcessStartInfo'), - { hostPlatform: getRemoteHostPlatform('win32-x64') } - ) - const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string - expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH') - expect(launcherSource).not.toContain('cmd.exe') - expect(launcherSource).not.toContain('%*') - expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') - expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false }) - expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe( - false - ) - }) - - it('reconnect re-attaches live PTYs', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2']) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2']) - - await session.reconnect(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1') - expect(mockAttach).toHaveBeenCalledWith('pty-2') - }) - - it('forwards reconnect replay after the attach attempt is still current', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue({ replay: 'restored-output' }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - await session.reconnect(mockConn) - - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: 'ssh:target-1@@pty-1', - data: 'restored-output' - }) - }) - - it('does not wall-clock dedupe identical replay payloads from distinct reconnects', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue({ replay: 'same-output' }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - await session.reconnect(mockConn) - await session.reconnect(mockConn) - - const replaySends = vi - .mocked(mockWindow.webContents.send) - .mock.calls.filter(([channel]) => channel === 'pty:replay') - expect(mockAttach).toHaveBeenCalledTimes(2) - expect(replaySends).toHaveLength(2) - }) - - it('establish re-attaches owned PTYs after explicit disconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1']) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1') - expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-1', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-1' - ]) - }) - - it('establish re-attaches durable leases after app restart', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { targetId: 'target-1', ptyId: 'pty-live', state: 'detached' }, - { targetId: 'target-1', ptyId: 'pty-live-2', state: 'detached' }, - // `expired` records that the CLIENT lost its route, so this is an orphan, not a corpse — the - // reattach is the only thing that can find out which. - { targetId: 'target-1', ptyId: 'pty-orphaned', state: 'expired' }, - // Retired routes, each for its own reason. Re-adopting the first is the lease fan-out; the - // second would hand this pane to whatever shell now holds the recycled id. - { targetId: 'target-1', ptyId: 'pty-superseded', state: 'expired', supersededBy: 'pty-live' }, - { - targetId: 'target-1', - ptyId: 'pty-recycled', - state: 'expired', - relayIdRecycled: true - }, - { targetId: 'target-1', ptyId: 'pty-terminated', state: 'terminated' } - ] as ReturnType) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-live') - expect(mockAttach).toHaveBeenCalledWith('pty-live-2') - expect(mockAttach).toHaveBeenCalledWith('pty-orphaned') - expect(mockAttach).not.toHaveBeenCalledWith('pty-superseded') - expect(mockAttach).not.toHaveBeenCalledWith('pty-recycled') - expect(mockAttach).not.toHaveBeenCalledWith('pty-terminated') - expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-live', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith( - 'target-1', - expect.arrayContaining(['pty-live', 'pty-live-2', 'pty-orphaned']) - ) - }) - - it('forwards a lease tab identity to reattach so a reset relay cannot cross-wire it', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { targetId: 'target-1', ptyId: 'pty-1', state: 'detached', tabId: 'tab-a' } - ] as ReturnType) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1', { tabId: 'tab-a' }) - }) - - it('forwards a lease pane identity when leaf identity is available', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - const leafId = '11111111-1111-4111-8111-111111111111' - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { targetId: 'target-1', ptyId: 'pty-1', state: 'detached', tabId: 'tab-a', leafId } - ] as ReturnType) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1', { - paneKey: `tab-a:${leafId}`, - tabId: 'tab-a' - }) - }) - - it('does not expire a live reused relay id when attach rejects identity mismatch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi - .fn() - .mockRejectedValueOnce(new Error('PTY "pty-1" not found (identity mismatch)')) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - const staleLeafId = '11111111-1111-4111-8111-111111111111' - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId: 'target-1', - ptyId: 'pty-1', - state: 'detached', - tabId: 'tab-old', - leafId: staleLeafId - } - ] as ReturnType) - - await session.reconnect(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1', { - paneKey: `tab-old:${staleLeafId}`, - tabId: 'tab-old' - }) - expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') - expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('target-1', 'pty-1', 'expired') - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'pty:exit', - expect.objectContaining({ id: 'ssh:target-1@@pty-1' }) - ) - }) - - it('rejects establish if detach wins while reattach is in flight', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - let resolveAttach!: () => void - const mockAttach = vi.fn().mockReturnValue( - new Promise((resolve) => { - resolveAttach = resolve - }) - ) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const establish = session.establish(mockConn) - await vi.waitFor(() => expect(mockAttach).toHaveBeenCalledWith('pty-1')) - session.detach() - resolveAttach() - - await expect(establish).rejects.toThrow('Session disposed during establish') - expect(setPtyOwnership).not.toHaveBeenCalledWith('pty-1', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - }) - - it('does not mark PTYs attached if detach wins while reattach is in flight', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - let resolveAttach!: () => void - const mockAttach = vi.fn().mockReturnValue( - new Promise((resolve) => { - resolveAttach = resolve - }) - ) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - const reconnect = session.reconnect(mockConn) - await vi.waitFor(() => expect(mockAttach).toHaveBeenCalledWith('pty-1')) - session.detach() - resolveAttach() - await reconnect - - expect(setPtyOwnership).not.toHaveBeenCalledWith('pty-1', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - }) - - it('invalidates and broadcasts remote PTYs that cannot reattach after relay reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi - .fn() - .mockRejectedValueOnce(new Error('PTY "pty-stale" not found')) - .mockResolvedValueOnce(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-stale', 'pty-live']) - - await session.reconnect(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-stale') - expect(mockAttach).toHaveBeenCalledWith('pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:target-1@@pty-stale') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-stale') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { - id: 'ssh:target-1@@pty-stale', - code: -1, - ptySourceDisowned: true - }) - }) - - it('retries transient reattach failure without tearing down provider registration', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onRelayLost = vi.fn() - session.setOnRelayLost(onRelayLost) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockRejectedValue(new Error('Multiplexer disposed')) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-live']) - const random = vi.spyOn(Math, 'random').mockReturnValue(0) - - try { - await session.reconnect(mockConn) - } finally { - random.mockRestore() - } - - expect(mockAttach).toHaveBeenCalledTimes(2) - expect(onRelayLost).not.toHaveBeenCalled() - expect(session.getState()).toBe('ready') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - }) - - it('dispose transitions to disposed and unregisters providers', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - session.dispose() - - expect(session.getState()).toBe('disposed') - expect(unregisterSshPtyProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshGitProvider).toHaveBeenCalledWith('target-1') - }) - - it('dispose is idempotent', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - session.dispose() - session.dispose() - - expect(session.getState()).toBe('disposed') - }) - - it('reconnect on disposed session is a no-op', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - session.dispose() - vi.clearAllMocks() - - await session.reconnect(mockConn) - - expect(deployAndLaunchRelay).not.toHaveBeenCalled() - }) - - it('overlapping reconnects cancel the stale one', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - // Why: make the first reconnect hang so the second one aborts it - let resolveFirst!: () => void - vi.mocked(deployAndLaunchRelay).mockReturnValueOnce( - new Promise((resolve) => { - resolveFirst = () => - resolve({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64' as const - }) - }) - ) - mockDeploySuccess() - - const firstReconnect = session.reconnect(mockConn) - const secondReconnect = session.reconnect(mockConn) - - resolveFirst() - await Promise.all([firstReconnect, secondReconnect]) - - expect(session.getState()).toBe('ready') - }) - - it('passes grace time to deployAndLaunchRelay', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn, 600) - - expect(deployAndLaunchRelay).toHaveBeenCalledWith(mockConn, undefined, 600, 'target-1') - }) - - it('restores the configured relay grace after establish', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn, 600) - - expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 600 - }) - }) - - it('sets relay grace to unlimited before host sleep', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(session.getMux()!.notify).mockClear() - - session.prepareForHostSleep() - - expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 0 - }) - }) - - it('cleans up port forwards on dispose', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - session.dispose() - - expect(mockPortForward.removeAllForwards).toHaveBeenCalledWith('target-1') - }) - - it('cleans up port forwards on reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - await session.reconnect(mockConn) - - expect(mockPortForward.removeAllForwards).toHaveBeenCalledWith('target-1') - }) - - it('establish cleans up mux and providers on partial registration failure', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - // Why: simulate registerRelayRoots failing after mux is created but - // before providers are fully registered. - mockStore.getRepos = vi.fn().mockImplementation(() => { - throw new Error('store error during root registration') - }) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await expect(session.establish(mockConn)).rejects.toThrow('store error') - expect(session.getState()).toBe('idle') - expect(session.getMux()).toBeNull() - expect(unregisterSshPtyProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshGitProvider).toHaveBeenCalledWith('target-1') - }) - - it('reconnect on idle session is a no-op', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.reconnect(mockConn) - - expect(session.getState()).toBe('idle') - expect(deployAndLaunchRelay).not.toHaveBeenCalled() - }) - - it('reconnect failure still allows retry from onStateChange', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - // Fail the first reconnect - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(new Error('deploy failed')) - await session.reconnect(mockConn) - expect(session.getState()).toBe('reconnecting') - - // Retry should work — reconnect accepts 'reconnecting' state - mockDeploySuccess() - await session.reconnect(mockConn) - expect(session.getState()).toBe('ready') - }) -}) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts deleted file mode 100644 index 4f13b47b1d2f..000000000000 --- a/src/main/ssh/ssh-relay-session.ts +++ /dev/null @@ -1,3578 +0,0 @@ -/* oxlint-disable max-lines */ -// Why: single authority for all relay lifecycle state per SSH target (previously scattered across module Maps/Sets with duplicated paths). - -import { randomUUID } from 'node:crypto' -import type { BrowserWindow } from 'electron' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' -import { SshPlainSshModeSession } from './ssh-plain-ssh-session' -import type { RemoteOpenCodeRuntimePreparation } from './ssh-relay-opencode-runtime-retry' -import { execCommand } from './ssh-relay-deploy-helpers' -import { writeStringsViaSftp } from './sftp-upload' -import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' -import { isRelayEndpointHeldError } from './ssh-relay-endpoint-incumbent' -import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' -import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' -import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay' -import { sweepOrphanedRelayPtys } from './ssh-orphan-relay-pty-sweep' -import { - clearPreviousRelayCensus, - isReattachHeldByPreviousRelay, - startPreviousRelayCensus -} from './ssh-previous-relay-terminals' -import { attachHeldPtyThroughPreviousRelay } from '../providers/ssh-pty-legacy-relay-delegation' -import { createSshLegacyRelayRouter } from './ssh-legacy-relay-routing' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshPtyProvider } from '../providers/ssh-pty-provider' -import type { SshPtyAttachResult } from '../providers/ssh-pty-session-reattach' -import type { SshPtyDataCallback, SshPtyExitCallback } from '../providers/ssh-pty-provider-contract' -import type { SshPtyRecoveryActivationLease } from '../providers/ssh-pty-notification-routing' -import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' -import { isMethodNotFoundError } from './ssh-filesystem-stream-reader' -import { SshGitProvider } from '../providers/ssh-git-provider' -import { selectOpenCodePluginSources } from '../agent-hooks/opencode-plugin-settings' -import { bindRemoteClaudeInterruptReconciliation } from './ssh-agent-hook-interrupt-reconciliation' -import { agentHookServer } from '../agent-hooks/server' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { - buildManagedHookDetectionCommands, - readManagedHookDetectionResult -} from '../agent-hooks/managed-hook-detection-commands' -import { - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_NOTIFICATION_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD, - isRemoteAgentHooksEnabled -} from '../../shared/agent-hook-relay' -import { AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES } from '../../shared/agent-status-legacy-adapter' -import { _internals as openCodeInternals } from '../opencode/hook-service' -import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source' -import { - getSshPtyProvider, - getPtyIdsForConnection, - clearPtyOwnershipForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership, - restorePtyIncarnation, - isCurrentPtyExit -} from '../ipc/pty' -import { - acceptSshPtyOutputData, - acceptSshPtyOutputExit, - allocateSshPtyProviderGeneration, - applySshPtySourceCancellationProof, - applySshPtySourceRecoveryCancellationProof, - beginSshPtyOutputGenerationMigration, - closeSshPtyOutputGeneration, - getSshPtyAcceptedSourceCheckpoints, - installSshPtySourceAckPublisher, - installSshPtySourceCancellationPublisher -} from '../ipc/ssh-pty-output-intake-registry' -import { registerSshHostProviders, retireSshHostProviders } from './ssh-host-provider-set' -import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' -import { PortScanner } from './ssh-port-scanner' -import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' -import type { SshPortForwardManager } from './ssh-port-forward' -import type { SshConnection } from './ssh-connection' -import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { makeRemoteDirectoryCommand } from './ssh-remote-commands' -import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - type DetectedPort, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, - sshRemotePtyLeaseAllowsReattach -} from '../../shared/ssh-types' -import { normalizeRemoteArtifactInput } from '../../shared/artifact-cli-bridge' -import type { Store } from '../persistence' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { - findTerminalTabIdForLeaf, - hasHostAuthoritativeTerminalMembership -} from '../persistence/terminal-topology/terminal-topology-membership' -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' -import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' -import { - isSshOwnerAdmissionBlocked, - retrySshOwnerRecoveryWhileBlocked -} from './ssh-owner-recovery-retry' -import { - isSshOwnerAdmissionBlockedError, - SshOwnerAdmissionBlockedError -} from './ssh-owner-admission-blocked-error' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' -import { - acknowledgeRemoteOrcaCliPostOutput, - parseRemoteOrcaCliPostOutput -} from './ssh-remote-orchestration-post-output' -import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import { - SSH_AI_VAULT_LIST_SESSIONS_METHOD, - SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS, - type SshAiVaultRelayListParams, - type SshAiVaultRelayTitleParams -} from '../../shared/ssh-ai-vault-relay' -import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' -import { isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones' -import { - openSshPtyConsumerSession, - type OpenSshPtyConsumerSessionOptions, - type SshPtyConsumerAdmission, - type SshPtyConsumerOwnerState, - type SshPtyConsumerSessionState -} from './ssh-pty-consumer-session' -import type { - PtySourceRecoveryComplete, - PtySourceRecoveryPending, - PtySourceRecoveryRequest -} from '../../shared/pty-source-recovery-contract' -import { SshPtyRecoveryRetentionBudget } from './ssh-pty-recovery-retention-budget' -import { SshPtyRetiredSourceDeliveries } from './ssh-pty-retired-source-deliveries' -import { - claimSshPtyConsumerRecovery, - detachSshPtyConsumerRecovery, - forgetSshPtyConsumerRecovery, - getSshPtyConsumerRecovery, - rememberSshPtyConsumerRecovery -} from './ssh-pty-consumer-recovery' -import { classifySshPtyFrameRejection, SshPtyFrameRejectionLog } from './ssh-pty-frame-rejection' -import { SshPtyTargetedReattachQueue } from './ssh-pty-targeted-reattach-queue' - -export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' - -type SshPtyExitPayload = Parameters[0] -type SshPtyDataPayload = Parameters[0] -type SshPtyLease = ReturnType[number] -type ReattachedPtyRuntimeRestore = 'restored' | 'missing-surface' -const SSH_PTY_REATTACH_MAX_CONCURRENCY = 8 -const SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS = 10_000 -const SSH_PTY_REATTACH_RETRY_MIN_DELAY_MS = 50 -const SSH_PTY_REATTACH_RETRY_JITTER_MS = 200 -const SSH_REJECTED_PTY_RECOVERY_MAX_ATTEMPTS = 2 -// Why a second ceiling: the consecutive budget resets whenever a reattach succeeds, so a PTY that -// alternates recovered and rejected frames would otherwise reattach forever — each one costs a -// store read, an attach round trip and a store write. -const SSH_REJECTED_PTY_RECOVERY_MAX_GENERATION_ATTEMPTS = 12 -const SSH_REJECTED_PTY_RECOVERY_RETRY_DELAY_MS = 150 -const SSH_PLUGIN_INSTALL_RETRY_DELAY_MS = 5_000 -const SSH_PLUGIN_INSTALL_MAX_RETRIES = 3 -const SSH_SOURCE_RECOVERY_CANCELLATION_FAILED = 'ssh_source_recovery_cancellation_failed' - -// Why: superseded attempts stop quietly; a dead mux still owned by this attempt must enter recovery. -function verifyRelayAttempt( - mux: SshChannelMultiplexer, - isAttemptCurrent: () => boolean, - phase: string -): boolean { - if (!isAttemptCurrent()) { - return false - } - if (mux.isDisposed()) { - throw new Error(`Relay connection lost during ${phase}`) - } - return true -} - -type PendingPtyReattach = { - mux: SshChannelMultiplexer - providerGeneration: number - retentionKey: string - exits: SshPtyExitPayload[] - queuedData: SshPtyDataPayload[] - recoveryData: SshPtyDataPayload[] - liveData: SshPtyDataPayload[] - recovery?: PtySourceRecoveryPending - recoveryComplete?: PtySourceRecoveryComplete - nextRecoverySourceSu?: number - highestRecoverySourceEndSu?: number - replacementDeliveryToken?: string - restoreRequired?: string - recoveryWaiters: Set<() => void> - livePassthrough: boolean - activated: boolean -} - -type RemoteCliBridgeEnv = { - remoteHome: string - binDir: string - relayDir: string - nodePath: string - sockPath: string - credentialFile?: string - hostPlatform: RemoteHostPlatform - pathDelimiter?: ':' | ';' -} - -type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } -type TargetedDeliveryRecovery = 'confirm-existing' | 'fresh-activation' - -function expectedIdentityForLease(lease: { - tabId?: string - leafId?: string -}): ExpectedPtyIdentity | null { - if (typeof lease.tabId !== 'string' || lease.tabId.length === 0) { - return null - } - const paneKey = - isValidTerminalTabId(lease.tabId) && - typeof lease.leafId === 'string' && - isTerminalLeafId(lease.leafId) - ? makePaneKey(lease.tabId, lease.leafId) - : undefined - return { - ...(paneKey ? { paneKey } : {}), - tabId: lease.tabId - } -} - -function parseRecoveryComplete(params: Record): PtySourceRecoveryComplete | null { - if ( - typeof params.id !== 'string' || - typeof params.deliveryToken !== 'string' || - params.deliveryToken.length === 0 || - typeof params.ptyIncarnation !== 'string' || - params.ptyIncarnation.length === 0 || - !positiveSafeInteger(params.clientGeneration) || - !positiveSafeInteger(params.ownerGeneration) || - !nonNegativeSafeInteger(params.checkpointSourceEndSu) || - !nonNegativeSafeInteger(params.recoveryEndSu) || - Number(params.recoveryEndSu) < Number(params.checkpointSourceEndSu) - ) { - return null - } - return Object.freeze({ - id: params.id, - deliveryToken: params.deliveryToken, - ptyIncarnation: params.ptyIncarnation, - clientGeneration: Number(params.clientGeneration), - ownerGeneration: Number(params.ownerGeneration), - checkpointSourceEndSu: Number(params.checkpointSourceEndSu), - recoveryEndSu: Number(params.recoveryEndSu) - }) -} - -function positiveSafeInteger(value: unknown): boolean { - return Number.isSafeInteger(value) && Number(value) > 0 -} - -function nonNegativeSafeInteger(value: unknown): boolean { - return Number.isSafeInteger(value) && Number(value) >= 0 -} - -function sourceRecoveryCancellationError(cause: unknown): Error { - return Object.assign(new Error(SSH_SOURCE_RECOVERY_CANCELLATION_FAILED), { - code: SSH_SOURCE_RECOVERY_CANCELLATION_FAILED, - cause - }) -} - -function isSourceRecoveryCancellationError(error: unknown): boolean { - return (error as { code?: unknown })?.code === SSH_SOURCE_RECOVERY_CANCELLATION_FAILED -} - -export type SshRelayAiVaultHostInfo = { - targetId: string - executionHostId: ExecutionHostId - remoteHome: string - hostPlatform: RemoteHostPlatform -} - -function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): number { - const raw = graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS - const requested = Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS - return requested === 0 - ? 0 - : Math.max( - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requested) - ) -} - -// Why: teardown barriers are independent, so one failing store write must not hide the others — -// settle them all and aggregate, rather than rethrowing only whichever rejected first. -async function settleSshSessionTeardown( - barriers: (Promise | null | undefined)[] -): Promise { - const results = await Promise.allSettled(barriers.map((barrier) => barrier ?? Promise.resolve())) - const errors = results.flatMap((result) => - result.status === 'rejected' ? [result.reason as unknown] : [] - ) - if (errors.length === 1) { - throw errors[0] - } - if (errors.length > 1) { - throw new AggregateError(errors, 'SSH relay session teardown failed') - } -} - -// Why: dispose is strictly more destructive than detach, so the mode records which teardown a -// session has already committed to and lets dispose supersede an in-flight detach. -type SshRelaySessionTeardownMode = 'detach' | 'dispose' - -export class SshRelaySession { - private _state: RelaySessionState = 'idle' - private mux: SshChannelMultiplexer | null = null - private abortController: AbortController | null = null - private muxDisposeCleanup: (() => void) | null = null - // Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup). - private remoteInterruptCleanup: (() => void) | null = null - private muxNotificationCleanup: (() => void) | null = null - private pluginSettingsCleanup: (() => void) | null = null - private pluginInstallRetryTimer: ReturnType | null = null - private pluginInstallGeneration = 0 - // Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect. - private _onRelayLost: ((targetId: string) => void) | null = null - // Why: a version mismatch or a blocked owner admission is terminal, so it needs a separate callback - // from _onRelayLost (which expects a recoverable transport drop). - private _onTerminalRelayError: ((targetId: string, err: Error) => void) | null = null - private _onReady: ((targetId: string) => void) | null = null - private portScanner: PortScanner | null = null - private currentConnection: SshConnection | null = null - private previousRelayCensus: ReturnType | undefined - // Why: a self-driven repair reconnect must not silently re-negotiate the target's grace window. - private lastGraceTimeSeconds: number | undefined = undefined - private hostPlatform: RemoteHostPlatform | null = null - private plainSsh: SshPlainSshModeSession | null = null - private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null - private openCodeRuntimePreparation: { - run: RemoteOpenCodeRuntimePreparation - controller: AbortController - } | null = null - private readonly unsupportedRelayMethods = new Set() - private pendingPtyReattaches = new Map() - private readonly ptyRecoveryRetention = new SshPtyRecoveryRetentionBudget() - private activePtyProviderGeneration: number | null = null - private sourceAckPublisherCleanup: (() => void) | null = null - private sourceCancellationPublisherCleanup: (() => void) | null = null - private teardownMode: SshRelaySessionTeardownMode | null = null - private teardownCompletion: Promise | null = null - // Why: detach's in-memory half is one-shot but its lease write is retryable, so they are tracked - // apart — a rejected write can be re-issued without re-running provider teardown. - private detachedInMemory = false - private detachFlushRejected = false - private ptyRecoveryNotificationCleanups: (() => void)[] = [] - private readonly sourceIdentityByRelayPtyId = new Map< - string, - Readonly<{ - deliveryToken: string - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - nextSourceSu?: number - }> - >() - private readonly retiredSourceDeliveries = new SshPtyRetiredSourceDeliveries() - private readonly rejectedPtyRecoveryAttempts = new Map< - string, - { - providerGeneration: number - attempts: number - generationAttempts: number - reported: boolean - } - >() - private readonly rejectedPtyRecoveryRetries = new Set>() - private readonly rejectedPtyReattaches = new SshPtyTargetedReattachQueue( - SSH_PTY_REATTACH_MAX_CONCURRENCY - ) - private readonly ptyFrameRejectionLog = new SshPtyFrameRejectionLog() - private readonly ptyConsumerClientInstanceId: string - private ptyConsumerSessionState: SshPtyConsumerSessionState | null = null - private activeCompatibilityAttachmentIds = new Set() - - constructor( - readonly targetId: string, - private getMainWindow: () => BrowserWindow | null, - private store: Store, - private portForwardManager: SshPortForwardManager, - private runtime?: OrcaRuntimeService, - private onDetectedPortsChanged?: ( - targetId: string, - ports: DetectedPort[], - platform: string - ) => void - ) { - this.ptyConsumerClientInstanceId = claimSshPtyConsumerRecovery(targetId, store).clientInstanceId - } - - refreshEnvironment( - getMainWindow: () => BrowserWindow | null, - store: Store, - portForwardManager: SshPortForwardManager, - runtime?: OrcaRuntimeService, - onDetectedPortsChanged?: (targetId: string, ports: DetectedPort[], platform: string) => void - ): void { - this.getMainWindow = getMainWindow - this.store = store - this.portForwardManager = portForwardManager - this.runtime = runtime - this.onDetectedPortsChanged = onDetectedPortsChanged - } - - setOnRelayLost(cb: (targetId: string) => void): void { - this._onRelayLost = cb - } - - setOnTerminalRelayError(cb: (targetId: string, err: Error) => void): void { - this._onTerminalRelayError = cb - } - - setOnReady(cb: (targetId: string) => void): void { - this._onReady = cb - } - - getState(): RelaySessionState { - return this._state - } - - // Why: dispose() can mutate _state across await points, so defeat TS's control-flow narrowing that would otherwise reject the 'disposed' check. - private isDisposed(): boolean { - return (this._state as RelaySessionState) === 'disposed' - } - - private requireReadyConnection(): SshConnection { - if (!this.currentConnection) { - throw new Error('SSH connection is not active') - } - return this.currentConnection - } - - getMux(): SshChannelMultiplexer | null { - return this.mux - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform - } - - /** The host's own `$HOME`, read on the host during relay deploy — never this client's. */ - getRemoteHomeDirectory(): string | null { - return this.remoteCliBridgeEnv?.remoteHome ?? null - } - - getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { - const env = this.remoteCliBridgeEnv - if (!env) { - return null - } - return { - targetId: this.targetId, - executionHostId: toSshExecutionHostId(this.targetId), - remoteHome: env.remoteHome, - hostPlatform: env.hostPlatform - } - } - - async requestSessionSearch(method: string, params: Record): Promise { - const mux = this.mux - if (!mux || mux.isDisposed() || this._state !== 'ready') { - throw new Error('SSH relay is not ready') - } - this.prepareOpenCodeRuntimeForScan() - return mux.request(method, params, { timeoutMs: 15_000 }) - } - - async requestAiVaultSessionList( - params: SshAiVaultRelayListParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { - return this.requestOptionalRelayMethod( - SSH_AI_VAULT_LIST_SESSIONS_METHOD, - params, - options, - SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS, - () => this.prepareOpenCodeRuntimeForScan() - ) - } - - async requestAiVaultSessionTitles( - params: SshAiVaultRelayTitleParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { - return this.requestOptionalRelayMethod( - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, - params, - options, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS - ) - } - - private async requestOptionalRelayMethod( - method: string, - params: Record, - options: { signal?: AbortSignal; timeoutMs?: number }, - defaultTimeoutMs: number, - before?: () => void - ): Promise { - if (this.unsupportedRelayMethods.has(method)) { - return null - } - const mux = this.mux - if (!mux || mux.isDisposed() || this._state !== 'ready') { - throw new Error('SSH relay is not ready') - } - before?.() - try { - return await mux.request(method, params, { - signal: options.signal, - timeoutMs: options.timeoutMs ?? defaultTimeoutMs - }) - } catch (error) { - // Why: older relays lack optional methods; remember per session so callers degrade to null. - if (isMethodNotFoundError(error)) { - this.unsupportedRelayMethods.add(method) - return null - } - throw error - } - } - - getPortScanner(): PortScanner | null { - return this.portScanner - } - - prepareForHostSleep(): void { - const mux = this.mux - if (!mux || mux.isDisposed() || this.isDisposed()) { - return - } - mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) - } - - // Why: single entry point for relay setup (initial connect + app-restart reconnect) so no path forgets a registration step. - async establish(conn: SshConnection, graceTimeSeconds?: number): Promise { - if (this._state !== 'idle') { - throw new Error(`Cannot establish relay session in state: ${this._state}`) - } - this._state = 'deploying' - this.unsupportedRelayMethods.clear() - this.currentConnection = conn - this.lastGraceTimeSeconds = graceTimeSeconds - - try { - const deployed = await this.deployRelayOrEnterPlainSsh( - conn, - graceTimeSeconds, - () => this._state === 'deploying' - ) - if (!deployed) { - return - } - const { - transport, - serverBuildId, - remoteHome, - remoteRelayDir, - nodePath, - sockPath, - credentialFile, - hostPlatform, - prepareOpenCodeRuntime - } = deployed - this.hostPlatform = hostPlatform ?? null - this.remoteCliBridgeEnv = - remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform - ? { - remoteHome, - binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), - relayDir: remoteRelayDir, - nodePath, - sockPath, - ...(credentialFile ? { credentialFile } : {}), - hostPlatform, - pathDelimiter: hostPlatform.pathDelimiter - } - : null - - // Why: dispose() can fire during the await above; if it did, creating a mux/providers now would leak with no owner to dispose them. - if (this.isDisposed()) { - const orphanMux = new SshChannelMultiplexer(transport) - orphanMux.dispose() - throw new Error('Session disposed during establish') - } - - const mux = new SshChannelMultiplexer(transport) - this.openCodeRuntimePreparation = prepareOpenCodeRuntime - ? { run: prepareOpenCodeRuntime, controller: new AbortController() } - : null - this.mux = mux - const isAttemptCurrent = (): boolean => this.mux === mux && !this.isDisposed() - const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() - - const ptyConsumerSessionState = await this.openPtyConsumerSession( - mux, - serverBuildId, - shouldContinue - ) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - this.ptyConsumerSessionState = ptyConsumerSessionState - await this.rememberPtyConsumerRecovery(serverBuildId) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - - await mux.request('session.resolveHome', { path: '~' }) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - const connectionIncarnation = randomUUID() - - const registered = await this.registerProviders(mux, shouldContinue, connectionIncarnation) - if (!registered) { - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - throw new Error('Relay provider registration stopped unexpectedly') - } - - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - - // Why: explicit disconnect keeps PTY ownership, so a later manual connect must reattach those remote PTYs. - await this.reattachKnownPtys(mux, shouldContinue) - - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { - throw new Error('Session disposed during establish') - } - - this.configureRelayGraceTime(mux, graceTimeSeconds) - verifyRelayAttempt(mux, isAttemptCurrent, 'establish') - this.watchMuxForRelayLoss(mux) - verifyRelayAttempt(mux, isAttemptCurrent, 'establish') - this._state = 'ready' - this.startPortScanning() - this._onReady?.(this.targetId) - } catch (err) { - // Why: registerProviders can throw with a live mux and partial registration — tear everything down so a retry starts clean. - if (!this.isDisposed()) { - this.teardownProviders( - 'connection_lost', - isSourceRecoveryCancellationError(err) - ? SSH_SOURCE_RECOVERY_CANCELLATION_FAILED - : 'connection_lost' - ) - this._state = 'idle' - } - // Why: terminal on first connect — a deployed binary against a still-running legacy daemon, or a - // claim another connection holds. Notify the callback but still rethrow. - // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the - // socket path, and backoff cannot make it hand it over. The user resolves it. - // RelayEndpointUnresponsiveError is deliberately NOT here: a relay that never answered - // may be stalled, and silence is not a decision — it falls through to retry. - if ( - isRelayVersionMismatchError(err) || - isRelayEndpointHeldError(err) || - isSshOwnerAdmissionBlockedError(err) - ) { - console.warn( - `[ssh-relay-session] Terminal relay error on initial connect for ${this.targetId}: ${err.message}` - ) - this._onTerminalRelayError?.(this.targetId, err) - } - throw err - } - } - - // Why: network-blip reconnect; AbortController-guarded so overlapping attempts from fast flaps cancel the stale one. - async reconnect(conn: SshConnection, graceTimeSeconds?: number): Promise { - // Why: reconnect only from 'ready'/'reconnecting' — from 'deploying' it would tear down a mux establish() is still using; 'idle' has no session yet. - if (this._state !== 'ready' && this._state !== 'reconnecting') { - return - } - - this.releaseRelayLossWatcher() - // Cancel any in-flight reconnect - this.abortController?.abort() - const abortController = new AbortController() - this.abortController = abortController - - this._state = 'reconnecting' - this.unsupportedRelayMethods.clear() - this.currentConnection = conn - this.lastGraceTimeSeconds = graceTimeSeconds - - // Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer. - this.stopPortScanning() - await this.portForwardManager.removeAllForwards(this.targetId) - this.broadcastEmptyLists() - this.teardownProviders('connection_lost') - - try { - const deployed = await this.deployRelayOrEnterPlainSsh( - conn, - graceTimeSeconds, - () => this.abortController === abortController && !abortController.signal.aborted - ) - if (!deployed) { - return - } - const { - transport, - serverBuildId, - remoteHome, - remoteRelayDir, - nodePath, - sockPath, - credentialFile, - hostPlatform, - prepareOpenCodeRuntime - } = deployed - this.hostPlatform = hostPlatform ?? null - this.remoteCliBridgeEnv = - remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform - ? { - remoteHome, - binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), - relayDir: remoteRelayDir, - nodePath, - sockPath, - ...(credentialFile ? { credentialFile } : {}), - hostPlatform, - pathDelimiter: hostPlatform.pathDelimiter - } - : null - - if (abortController.signal.aborted || this.isDisposed()) { - // Why: relay is already running remotely — a throwaway mux we immediately dispose sends a clean shutdown so it doesn't linger until grace expires. - const orphanMux = new SshChannelMultiplexer(transport) - orphanMux.dispose() - return - } - - const mux = new SshChannelMultiplexer(transport) - this.openCodeRuntimePreparation = prepareOpenCodeRuntime - ? { run: prepareOpenCodeRuntime, controller: new AbortController() } - : null - this.mux = mux - - const isAttemptCurrent = (): boolean => - this.mux === mux && - this.abortController === abortController && - !abortController.signal.aborted && - !this.isDisposed() - const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() - - const ptyConsumerSessionState = await this.openPtyConsumerSession( - mux, - serverBuildId, - shouldContinue - ) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { - if (!mux.isDisposed()) { - mux.dispose() - } - return - } - this.ptyConsumerSessionState = ptyConsumerSessionState - await this.rememberPtyConsumerRecovery(serverBuildId) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { - if (!mux.isDisposed()) { - mux.dispose() - } - return - } - - await mux.request('session.resolveHome', { path: '~' }) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { - if (!mux.isDisposed()) { - mux.dispose() - } - return - } - const connectionIncarnation = randomUUID() - - const registered = await this.registerProviders(mux, shouldContinue, connectionIncarnation) - if (!registered) { - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (this.mux === mux) { - this.teardownProviders('shutdown') - } else if (!mux.isDisposed()) { - mux.dispose() - } - return - } - throw new Error('Relay provider registration stopped unexpectedly') - } - - // Why: dispose() during registration/attach already cleaned up, but this.mux was reassigned above — clean up the new mux so it doesn't leak. - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (this.mux === mux) { - this.teardownProviders('shutdown') - } else if (!mux.isDisposed()) { - mux.dispose() - } - return - } - - await this.reattachKnownPtys(mux, shouldContinue) - - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { - return - } - - this.configureRelayGraceTime(mux, graceTimeSeconds) - verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') - this.watchMuxForRelayLoss(mux) - verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') - this._state = 'ready' - this.startPortScanning() - this._onReady?.(this.targetId) - } catch (err) { - // Why: tear down a partially-registered mux so its keepalive/timeout timers don't keep running on a half-initialized session. - if (this.abortController === abortController && !this.isDisposed()) { - this.teardownProviders( - 'connection_lost', - isSourceRecoveryCancellationError(err) - ? SSH_SOURCE_RECOVERY_CANCELLATION_FAILED - : 'connection_lost' - ) - } - // Why terminal: neither a version mismatch nor a blocked owner claim is reconcilable by backoff - // retry, so fire the typed callback and drop out of 'reconnecting'. - // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the - // socket path, and backoff cannot make it hand it over. The user resolves it. - if ( - isRelayVersionMismatchError(err) || - isRelayEndpointHeldError(err) || - isSshOwnerAdmissionBlockedError(err) - ) { - console.warn( - `[ssh-relay-session] Terminal relay error for ${this.targetId}: ${err.message}` - ) - if (this.abortController === abortController && !this.isDisposed()) { - this._state = 'idle' - } - this._onTerminalRelayError?.(this.targetId, err) - return - } - // Why: stay in 'reconnecting' (not 'ready') since the provider stack is torn down; the SSH manager will fire another onStateChange to retry. - console.warn( - `[ssh-relay-session] Failed to re-establish relay for ${this.targetId}: ${err instanceof Error ? err.message : String(err)}` - ) - if (this.abortController === abortController && !this.isDisposed()) { - // Why: treat non-not-found attach failures as relay loss so ssh.ts's bounded backoff retries instead of stranding the session in 'reconnecting'. - this._onRelayLost?.(this.targetId) - } - } finally { - if (this.abortController === abortController) { - this.abortController = null - } - } - } - - /** Fire-and-forget disposal; prefer {@link disposeAndPersist} when the caller can await durability. */ - dispose(): void { - void this.disposeAndPersist().catch((error) => { - console.warn( - `[ssh-relay-session] Failed to persist disposal for ${this.targetId}: ${error instanceof Error ? error.message : String(error)}` - ) - }) - } - - /** - * Destructive teardown: forgets the consumer recovery record and terminates the leases. - * Repeat calls share one completion, and a dispose requested after a detach supersedes it — - * the destructive half re-runs so detach-then-dispose still forgets recovery and terminates - * leases. (The reverse, detach after dispose, is a no-op.) - */ - disposeAndPersist(): Promise { - if (this.teardownMode === 'dispose') { - return this.teardownCompletion ?? Promise.resolve() - } - const pendingDetach = this.teardownCompletion - this.teardownMode = 'dispose' - try { - this.teardownCompletion = this.runDisposal(pendingDetach) - } catch (error) { - // Why: a synchronous failure in the in-memory half must ride the completion promise, or a later - // disposeAndPersist reports success on a null completion and detachAndPersist re-runs runDetach. - this.teardownCompletion = Promise.reject(error) - } - return this.teardownCompletion - } - - private runDisposal(pendingDetach: Promise | null): Promise { - // Why: the whole in-memory half runs before any await so a concurrent connect can never observe - // a half-torn session; only the durability barriers below are deferred onto the returned promise. - this.releaseRelayLossWatcher() - this.abortController?.abort() - this.stopPortScanning() - this.broadcastEmptyLists() - this.teardownProviders('shutdown') - this.currentConnection = null - this._state = 'disposed' - // Why here and not on reconnect: an explicit disconnect is user action, so the host earns a - // fresh node-pty repair attempt. A reconnect must not, or the repair becomes a loop. - forgetRelayNodePtyRepairs(this.targetId) - clearPreviousRelayCensus(this.targetId, this.previousRelayCensus) - const recoveryRemoval = forgetSshPtyConsumerRecovery( - this.targetId, - this.ptyConsumerClientInstanceId, - this.store - ) - const leaseTermination = this.store.markSshRemotePtyLeasesAsync(this.targetId, 'terminated') - return settleSshSessionTeardown([ - // Why: a superseded detach keeps its own rejection for its own caller; swallow it here so a - // failed detach write cannot fail the disposal that replaced it. - pendingDetach?.catch(() => undefined), - // Why: nothing rebinds after dispose, but direct callers (no IPC-side teardown) still need - // this session's local listeners released. - this.portForwardManager.removeAllForwards(this.targetId), - recoveryRemoval, - leaseTermination - ]) - } - - /** Fire-and-forget detach; prefer {@link detachAndPersist} when the caller can await durability. */ - detach(): void { - void this.detachAndPersist().catch((error) => { - console.warn( - `[ssh-relay-session] Failed to persist detach for ${this.targetId}: ${error instanceof Error ? error.message : String(error)}` - ) - }) - } - - /** - * Non-destructive teardown: keeps PTY ownership so a later connect reclaims this consumer - * identity. Once dispose has been requested this is a no-op — see {@link disposeAndPersist}. - */ - async detachAndPersist(): Promise { - // Why: a rejected lease write must not latch forever — re-issue just the write so the caller can - // retry. Never under 'dispose': that supersedes detach for good and re-issuing would resurrect - // the 'detached' state the disposal already replaced with 'terminated'. - if (!this.teardownCompletion || (this.teardownMode === 'detach' && this.detachFlushRejected)) { - this.teardownCompletion = this.runDetach() - } - this.teardownMode ??= 'detach' - let completion = this.teardownCompletion - while (completion) { - try { - await completion - } catch (error) { - if (completion === this.teardownCompletion) { - throw error - } - } - if (completion === this.teardownCompletion) { - return - } - completion = this.teardownCompletion - } - } - - // Why a separate transition from detachAndPersist: on the committed quit path every in-memory - // change has to land before the final store flush snapshots, and that flush — not a per-session - // durable write — is what persists it. Idempotent, and it schedules no persistence of its own, so - // nothing the async drain finishes later can write recovery state after the snapshot. - // - // 'detached' says this app let go of the lease, not that the remote shell died. The remote PTYs are - // left running for the next attach, exactly as an ordinary detach leaves them. - beginShutdownDetach(): void { - if (this.detachedInMemory || this._state === 'disposed') { - return - } - detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) - this.releaseRelayLossWatcher() - this.abortController?.abort() - this.stopPortScanning() - this.broadcastEmptyLists() - this.teardownProviders('connection_lost') - this.currentConnection = null - this._state = 'disposed' - this.detachedInMemory = true - this.store.markSshRemotePtyLeasesForShutdown(this.targetId, 'detached') - } - - private runDetach(): Promise { - if (!this.detachedInMemory) { - if (this._state === 'disposed') { - return Promise.resolve() - } - // Why first: same synchronous-half-first rule as runDisposal, and this is the highest-value - // step — a fast reconnect must reclaim this identity instead of minting one, even if a - // teardown call below throws unexpectedly. - detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) - this.releaseRelayLossWatcher() - this.abortController?.abort() - this.stopPortScanning() - this.broadcastEmptyLists() - // Why: disconnect keeps PTY ownership so a later manual connect can reattach. - this.teardownProviders('connection_lost') - this.currentConnection = null - this._state = 'disposed' - this.detachedInMemory = true - } - this.detachFlushRejected = false - return settleSshSessionTeardown([ - this.store.markSshRemotePtyLeasesAsync(this.targetId, 'detached') - ]).catch((error: unknown) => { - this.detachFlushRejected = true - throw error - }) - } - - /** Rung D connects with plain SSH providers, so the ladder's reason reaches the user connected. */ - getPlainSshSession(): SshPlainSshModeSession | null { - return this.plainSsh - } - - // ── Private ─────────────────────────────────────────────────────── - - private async deployRelayOrEnterPlainSsh( - conn: SshConnection, - graceTimeSeconds: number | undefined, - isAttemptCurrent: () => boolean - ): Promise> | null> { - try { - const deployed = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) - // Why gated: a superseded or disposed attempt would replace the current attempt's census. - if (isAttemptCurrent() && !this.isDisposed()) { - this.previousRelayCensus = startPreviousRelayCensus(conn, this.targetId, deployed) - } - return deployed - } catch (err) { - // Why system SSH is excluded: it has no ssh2 shell or SFTP channel to degrade onto. - if ( - !(err instanceof RemoteRuntimeUnavailableError) || - conn.usesSystemSshTransport?.() === true || - this.isDisposed() || - !isAttemptCurrent() - ) { - throw err - } - // Why: a superseded attempt's session must not stay registered beside this one. - this.leavePlainSshMode() - this.plainSsh = SshPlainSshModeSession.enter({ - targetId: this.targetId, - connection: conn, - error: err, - onExitAccepted: (payload) => this.retireExitedPty(payload, true) - }) - console.warn( - `[ssh-relay-session] ${this.targetId} connected in plain SSH mode: ${err.reason}` - ) - this._state = 'ready' - this._onReady?.(this.targetId) - return null - } - } - - private leavePlainSshMode(): void { - this.plainSsh?.leave() - this.plainSsh = null - } - - // Why: teardown itself can kill the mux — an aborted request emits rpc.cancel, and a saturated - // control lane turns that admission failure into mux.dispose('connection_lost'). Every teardown - // path must release the watcher before its first mux write, or our own shutdown re-enters - // recovery as a spurious relay loss. teardownProviders is not early enough: stopPortScanning - // runs ahead of it and is what emits that frame. Call this ahead of abortController.abort() - // too — that signal reaches no mux request today, but plumbing it into one would otherwise - // reopen the same hole silently. - private releaseRelayLossWatcher(): void { - this.muxDisposeCleanup?.() - this.muxDisposeCleanup = null - } - - // Why: onStateChange only fires on SSH-level reconnects, so watch for relay-channel loss while SSH stays up and fire onRelayLost. - private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { - this.releaseRelayLossWatcher() - this.muxDisposeCleanup = mux.onDispose((reason) => { - if (reason === 'connection_lost' && this.mux === mux && !this.isDisposed()) { - console.warn( - `[ssh-relay-session] Relay channel lost for ${this.targetId}, triggering reconnect` - ) - this._onRelayLost?.(this.targetId) - } - }) - } - - /** - * A spawn was refused because the relay cannot load node-pty. Reconnect once so the deploy - * path's `repairInstalledNativeDeps` rebuilds it under `tryAcquireRelayRepairLock`, then hand - * back the provider registered by that reconnect for a single retry. - * - * Nothing here mutates the remote directly — a lock-less rebuild could collide with a - * concurrent reconnect's repair, so the locked deploy path stays the only writer. If the lock - * is busy it launches degraded, the retry hits the same rejection, and the user sees the - * relay's message. The attempt is spent either way. - */ - private async recoverRemoteTerminalRuntime( - requestingProvider: SshPtyProvider, - cause: TerminalUnavailableCause - ): Promise { - const { provider } = await recoverRelayNodePtyForSpawn({ - targetId: this.targetId, - cause, - hasLivePtys: () => requestingProvider.hasLivePtys(), - reconnect: async () => { - const conn = this.currentConnection - if (!conn || this.isDisposed()) { - throw new Error('no_live_ssh_connection') - } - await this.reconnect(conn, this.lastGraceTimeSeconds) - }, - resolveProvider: () => { - if (this._state !== 'ready' || this.isDisposed()) { - return null - } - const current = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - // Why identity-checked: a reconnect that fell back to the same provider would retry - // against the same unrepaired relay. - return current && current !== requestingProvider ? current : null - } - }) - return provider - } - - // Why: shared by establish() and reconnect() so both use the exact same registration sequence. - private async registerProviders( - mux: SshChannelMultiplexer, - shouldContinue: (() => boolean) | undefined, - connectionIncarnation: string - ): Promise { - await this.registerRelayRoots(mux) - if (shouldContinue && !shouldContinue()) { - return false - } - - this.pluginSettingsCleanup?.() - this.pluginSettingsCleanup = - this.store.onSettingsChanged?.((updates) => { - if ('disabledTuiAgents' in updates || 'agentStatusHooksEnabled' in updates) { - void this.installPluginsOnRelay(mux) - } - }) ?? null - await this.installPluginsOnRelay(mux) - if (shouldContinue && !shouldContinue()) { - return false - } - - try { - await this.installRemoteOrcaCliLauncher() - } catch (error) { - // Why: on MaxSessions=1 remotes the relay holds the only slot, so this raw-connection install can fail — don't fail the whole connection. - console.warn( - `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - } - if (shouldContinue && !shouldContinue()) { - return false - } - - this.wireUpRemoteOrcaCli(mux, connectionIncarnation) - - const providerGeneration = allocateSshPtyProviderGeneration() - const ptyProvider = new SshPtyProvider( - this.targetId, - mux, - this.remoteCliBridgeEnv ?? undefined, - providerGeneration - ) - // Why optional-call: session tests register partial provider stubs, same as the pause adapter below. - ptyProvider.setTerminalUnavailableRecovery?.((cause) => - this.recoverRemoteTerminalRuntime(ptyProvider, cause) - ) - ptyProvider.setLegacyRelayRouting?.(this.createLegacyRelayRouter()) - const consumerOwnerState = this.activePtyConsumerOwner() - if (consumerOwnerState) { - ptyProvider.setPtyDeliveryPauseAdapter?.(({ id, providerGeneration: generation, paused }) => { - if ( - generation !== providerGeneration || - this.activePtyProviderGeneration !== providerGeneration || - this.mux !== mux - ) { - return - } - const sourceIdentity = this.sourceIdentityByRelayPtyId.get(id) - if (consumerOwnerState.outputFlowControl && !sourceIdentity) { - return - } - mux.notify('pty.setDeliveryPaused', { - id, - paused, - clientGeneration: consumerOwnerState.clientGeneration, - ownerGeneration: consumerOwnerState.ownerGeneration, - ...(sourceIdentity ? { deliveryToken: sourceIdentity.deliveryToken } : {}) - }) - }) - } - this.sourceAckPublisherCleanup?.() - this.sourceAckPublisherCleanup = null - this.sourceCancellationPublisherCleanup?.() - this.sourceCancellationPublisherCleanup = null - if (consumerOwnerState?.outputFlowControl) { - this.sourceAckPublisherCleanup = installSshPtySourceAckPublisher( - providerGeneration, - // ACK delivery is idempotent and re-derived from credit state, so it consumes - // the two-valued projection of the write settlement rather than the three arms. - (batch, onSettled) => - mux.notifyWithSettlement( - 'pty.ackData', - batch as unknown as Record, - (settlement) => - onSettled( - settlement.outcome === 'accepted' - ? { ok: true } - : { ok: false, error: settlement.error } - ) - ) - ) - this.sourceCancellationPublisherCleanup = installSshPtySourceCancellationPublisher( - providerGeneration, - async (request) => { - const result = (await mux.request('pty.cancelDelivery', { - ...request, - id: toRelaySshPtyId(this.targetId, request.id) - })) as Record - if ( - result.canceled !== true || - !Number.isSafeInteger(result.sentEndSu) || - !Number.isSafeInteger(result.creditedEndSu) - ) { - throw new Error('ssh_source_cancellation_proof_invalid') - } - return { - sentEndSu: result.sentEndSu as number, - creditedEndSu: result.creditedEndSu as number - } - } - ) - } - this.activePtyProviderGeneration = providerGeneration - const connection = this.requireReadyConnection() - const createSftp = - connection.usesSystemSshTransport?.() === true - ? undefined - : (options?: { signal?: AbortSignal }) => this.requireReadyConnection().sftp(options) - // Why: getHostPlatform() falls back to this.hostPlatform when bridge env is incomplete, so path rules still match the host. - const hostPlatform = this.getHostPlatform() ?? undefined - const fsProvider = new SshFilesystemProvider( - this.targetId, - mux, - createSftp, - { - downloadFile: (sourcePath, destinationPath) => - this.requireReadyConnection().downloadFile(sourcePath, destinationPath, { - hostPlatform - }), - openFileUploadSession: () => - this.requireReadyConnection().openFileUploadSession({ - hostPlatform - }), - writeBuffer: (remotePath, contents, options) => - this.requireReadyConnection().writeBuffer(remotePath, contents, { - hostPlatform, - append: options.append, - exclusive: options.exclusive - }) - }, - hostPlatform - ) - const gitProvider = new SshGitProvider( - this.targetId, - mux, - this.remoteCliBridgeEnv?.hostPlatform ?? null - ) - // Why built first: a throw while building one must not leave the host half-registered. - registerSshHostProviders(this.targetId, { pty: ptyProvider, fs: fsProvider, git: gitProvider }) - this.installPtyRecoveryNotifications(mux) - - this.wireUpPtyEvents(ptyProvider, mux, providerGeneration) - this.wireUpAgentHookEvents(mux) - this.wireUpRemoteWorkspaceEvents(mux) - void this.installManagedHooksOnRemote(mux, shouldContinue) - return true - } - - // Why not acceptPtyData: an earlier build's relay serves these panes without flow control. - private createLegacyRelayRouter(): ReturnType { - return createSshLegacyRelayRouter({ - targetId: this.targetId, - connection: () => this.currentConnection, - clientInstanceId: this.ptyConsumerClientInstanceId, - sink: { - data: (payload) => - void acceptSshPtyOutputData({ - id: payload.id, - data: payload.data, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation, - rawLength: payload.sequenceChars ?? payload.data.length, - transformed: payload.transformed === true, - ...(typeof payload.seq === 'number' ? { sequence: payload.seq } : {}) - }).catch(() => {}), - exit: (payload) => void this.acceptPtyExit(payload).catch(() => {}), - replay: (payload) => this.forwardReattachReplay(payload.id, payload.data) - } - }) - } - - private activePtyConsumerOwner(): SshPtyConsumerOwnerState | null { - const state = this.ptyConsumerSessionState - return state && state.mode !== 'legacy-fallback' ? state : null - } - - private recoverablePtyConsumerOwner( - serverBuildId: string | undefined - ): SshPtyConsumerOwnerState | null { - const active = this.activePtyConsumerOwner() - if (active) { - return active - } - const recovery = getSshPtyConsumerRecovery(this.targetId) - return serverBuildId && recovery?.serverBuildId === serverBuildId - ? (recovery?.owner ?? null) - : null - } - - private async openPtyConsumerSession( - mux: SshChannelMultiplexer, - serverBuildId: string | undefined, - ownsAttempt: () => boolean - ): Promise { - const previousOwner = this.recoverablePtyConsumerOwner(serverBuildId) - const options: OpenSshPtyConsumerSessionOptions = { - clientInstanceId: this.ptyConsumerClientInstanceId, - expectedServerBuildId: serverBuildId, - allowSameBuildLegacyFallback: true, - outputFlowControl: { requestedWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } - } - let admission: SshPtyConsumerAdmission - try { - admission = await this.admitPtyConsumerOwner(mux, previousOwner, options, ownsAttempt) - } catch (error) { - if ( - !previousOwner || - (error as { code?: unknown }).code !== PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR - ) { - throw error - } - this.voidPtyConsumerCheckpoints(previousOwner, ownsAttempt) - if (!ownsAttempt()) { - throw new Error('Session disposed during owner recovery') - } - admission = await openSshPtyConsumerSession(mux, options) - } - if (previousOwner && !admission.resumed) { - this.voidPtyConsumerCheckpoints(previousOwner, ownsAttempt) - } - return admission.state - } - - private async admitPtyConsumerOwner( - mux: SshChannelMultiplexer, - previousOwner: SshPtyConsumerOwnerState | null, - options: OpenSshPtyConsumerSessionOptions, - ownsAttempt: () => boolean - ): Promise { - try { - return await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - ...options, - ...(previousOwner - ? { - resume: { - ownerGeneration: previousOwner.ownerGeneration, - ownerLease: previousOwner.ownerLease - } - } - : {}) - }), - { - isCurrent: () => ownsAttempt() && !mux.isDisposed(), - onClosed: (listener) => mux.onDispose(listener) - } - ) - } catch (error) { - // Why converted here: past this point the failure travels the same path as a dropped transport, - // where backoff would keep redeploying a relay that is working fine and refusing on purpose. - if (isSshOwnerAdmissionBlocked(error)) { - throw new SshOwnerAdmissionBlockedError(this.targetId, { cause: error }) - } - throw error - } - } - - // Why the recovery row and clientInstanceId survive: the relay minted a fresh claim, which voids the - // checkpoints taken under the old one but says nothing about our identity for this target. The caller - // durably records the new lease before ready, so removal here would only lose the identity. - private voidPtyConsumerCheckpoints( - previousOwner: SshPtyConsumerOwnerState, - ownsAttempt: () => boolean - ): void { - const recovery = getSshPtyConsumerRecovery(this.targetId) - // Why identity-guarded: the record is target-scoped and its clientInstanceId is shared by every - // session for that target, so only a record still describing the owner this attempt tried to - // resume is ours to void — otherwise a loser wipes the winner's checkpoints. - if ( - !recovery || - !ownsAttempt() || - (recovery.owner && - (recovery.owner.ownerGeneration !== previousOwner.ownerGeneration || - recovery.owner.ownerLease !== previousOwner.ownerLease)) - ) { - return - } - delete recovery.owner - recovery.checkpointsByAppPtyId.clear() - for (const [ptyId, migration] of recovery.modelMigrationsByAppPtyId) { - recovery.modelMigrationsByAppPtyId.set( - ptyId, - migration.then(() => - Object.freeze({ - status: 'checkpoint-unavailable' as const, - reason: 'completion-failed' as const - }) - ) - ) - } - } - - private async rememberPtyConsumerRecovery(serverBuildId: string | undefined): Promise { - const owner = this.activePtyConsumerOwner() - if (!owner || !serverBuildId) { - return - } - await rememberSshPtyConsumerRecovery({ - targetId: this.targetId, - clientInstanceId: this.ptyConsumerClientInstanceId, - serverBuildId, - owner, - store: this.store - }) - } - - private configureRelayGraceTime( - mux: SshChannelMultiplexer, - graceTimeSeconds: number | undefined - ): void { - mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: normalizeRelayGracePeriodSeconds(graceTimeSeconds) - }) - } - - private async installManagedHooksOnRemote( - mux: SshChannelMultiplexer, - shouldContinue?: () => boolean - ): Promise { - if ( - !isRemoteAgentHooksEnabled() || - !this.areAgentStatusHooksEnabled() || - (shouldContinue && !shouldContinue()) - ) { - return - } - if ( - this.remoteCliBridgeEnv?.hostPlatform && - isWindowsRemoteHost(this.remoteCliBridgeEnv.hostPlatform) - ) { - // Why: managed hook installers emit POSIX-only scripts/paths; Windows remotes rely on relay-injected env + plugin overlays instead. - return - } - - try { - const store = this.store as { getSettings?: Store['getSettings'] } - const detected = readManagedHookDetectionResult( - await mux.request('preflight.detectAgents', { - commands: buildManagedHookDetectionCommands(store.getSettings?.() ?? null, 'linux') - }) - ) - const agents = detected.agents - if (agents.length === 0 || (shouldContinue && !shouldContinue())) { - return - } - const hostKeyFingerprint = this.requireReadyConnection().getHostKeyFingerprint?.() - const params = { - ...(hostKeyFingerprint ? { hostKeyFingerprint } : {}), - agents, - ...(detected.claudeVersion ? { claudeVersion: detected.claudeVersion } : {}) - } - const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as { - errors?: unknown - } - if (typeof result.errors === 'number' && result.errors > 0) { - console.warn( - `[ssh-relay-session] ${result.errors} remote managed hook installers failed for ${this.targetId}` - ) - } - } catch (error) { - // Why: teardown routinely cancels this best-effort request; only warn for - // installer failures that survive the connection lifecycle. - const code = (error as { code?: unknown })?.code - if ( - code === -32601 || - code === 'CONNECTION_LOST' || - code === 'DISPOSED' || - mux.isDisposed() - ) { - return - } - console.warn( - `[ssh-relay-session] relay managed hook install failed for ${this.targetId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - } - } - - private async installRemoteOrcaCliLauncher(): Promise { - if (!this.remoteCliBridgeEnv) { - return - } - const { binDir, hostPlatform } = this.remoteCliBridgeEnv - const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv) - const conn = this.requireReadyConnection() - await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { - wrapCommand: !isWindowsRemoteHost(hostPlatform) - }) - if (typeof conn.writeFile === 'function') { - for (const file of plan.files) { - await conn.writeFile(file.path, file.contents, { hostPlatform }) - } - } else { - await writeStringsViaSftp(conn, plan.files) - } - for (const command of plan.postWriteCommands) { - await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) - } - } - - // Why: read per invocation so turning the per-host opt-in off takes effect on the next command. - private remoteCliCallerScope(): SshBridgeCallerScope { - return { - kind: 'ssh-bridge', - targetId: this.targetId, - remoteCliControl: this.store.getSshTarget(this.targetId)?.allowRemoteCliControl === true - } - } - - private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer, connectionIncarnation: string): void { - mux.onRequest('orca.cli', async (params) => { - if (!this.runtime) { - throw new Error('Orca runtime is unavailable') - } - const argv = Array.isArray(params.argv) - ? params.argv.filter((item): item is string => typeof item === 'string') - : [] - const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : '/' - const rawEnv = params.env - const env = - rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv) - ? Object.fromEntries( - Object.entries(rawEnv).filter( - (entry): entry is [string, string] => - typeof entry[0] === 'string' && typeof entry[1] === 'string' - ) - ) - : {} - const stdin = typeof params.stdin === 'string' ? params.stdin : undefined - const artifactInput = normalizeRemoteArtifactInput(params.artifactInput) - const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment( - this.targetId, - connectionIncarnation - ) - this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId) - try { - return await runRemoteOrcaCli(this.runtime, { - argv, - cwd, - env, - ...(stdin !== undefined ? { stdin } : {}), - ...(artifactInput ? { artifactInput } : {}), - runtimeAuthority, - callerScope: this.remoteCliCallerScope() - }) - } finally { - this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId) - this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId) - } - }) - mux.onRequest('orca.cli.postOutput', async (params) => { - if (!this.runtime) { - throw new Error('Orca runtime is unavailable') - } - const rawEnv = params.env - const env = - rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv) - ? Object.fromEntries( - Object.entries(rawEnv).filter( - (entry): entry is [string, string] => - typeof entry[0] === 'string' && typeof entry[1] === 'string' - ) - ) - : {} - const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment( - this.targetId, - connectionIncarnation - ) - this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId) - try { - await acknowledgeRemoteOrcaCliPostOutput(this.runtime, { - postOutput: parseRemoteOrcaCliPostOutput(params.postOutput), - env, - runtimeAuthority, - callerScope: this.remoteCliCallerScope() - }) - return { acknowledged: true } - } finally { - this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId) - this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId) - } - }) - } - - // Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy — the relay is versioned independently. Best-effort: failure only costs agent status on this host. - private async installPluginsOnRelay( - mux: SshChannelMultiplexer, - attempt = 0, - generation = ++this.pluginInstallGeneration - ): Promise { - if (!isRemoteAgentHooksEnabled()) { - return - } - try { - this.clearPluginInstallRetry() - const hooksEnabled = this.areAgentStatusHooksEnabled() - await mux.request( - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - selectOpenCodePluginSources( - { - opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), - opencode2PluginSource: openCodeInternals.getOpenCode2PluginSource(), - ...(hooksEnabled - ? { - piExtensionSource: getPiAgentStatusExtensionSource('pi'), - ompExtensionSource: getPiAgentStatusExtensionSource('omp'), - primeAgentExtensionSource: getPiAgentStatusExtensionSource('prime-agent') - } - : {}) - }, - this.store.getSettings?.() ?? null - ) - ) - } catch (err) { - // Why: -32601 = older relay without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both. - const code = err instanceof Error && 'code' in err ? err.code : undefined - if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { - return - } - if (mux.isDisposed()) { - return - } - console.warn( - `[ssh-relay-session] agent_hook.installPlugins failed for ${this.targetId}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - if (code === 'SSH_MUX_REQUEST_TIMEOUT') { - this.schedulePluginInstallRetry(mux, attempt, generation) - } - } - } - - private schedulePluginInstallRetry( - mux: SshChannelMultiplexer, - attempt: number, - generation: number - ): void { - if ( - attempt >= SSH_PLUGIN_INSTALL_MAX_RETRIES || - this.mux !== mux || - generation !== this.pluginInstallGeneration - ) { - return - } - this.pluginInstallRetryTimer = setTimeout(() => { - this.pluginInstallRetryTimer = null - if (this.mux === mux && !mux.isDisposed()) { - void this.installPluginsOnRelay(mux, attempt + 1, generation) - } - }, SSH_PLUGIN_INSTALL_RETRY_DELAY_MS) - this.pluginInstallRetryTimer.unref?.() - } - - private clearPluginInstallRetry(): void { - if (this.pluginInstallRetryTimer !== null) { - clearTimeout(this.pluginInstallRetryTimer) - this.pluginInstallRetryTimer = null - } - } - - private areAgentStatusHooksEnabled(): boolean { - const store = this.store as { getSettings?: Store['getSettings'] } - return isAgentStatusHooksEnabled(store.getSettings?.()) - } - - private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void { - mux.onNotification((method, params) => { - notifyRemoteWorkspaceHandlers(this.targetId, method, params) - }) - } - - // Why: relay sends connectionId:null, so stamp this.targetId here so the renderer can drop events from torn-down connections. - private wireUpAgentHookEvents(mux: SshChannelMultiplexer): void { - if (!isRemoteAgentHooksEnabled()) { - return - } - // Why: capture the disposer so teardownProviders can release this handler and re-wiring can't double-register it. - this.muxNotificationCleanup?.() - this.remoteInterruptCleanup?.() - this.remoteInterruptCleanup = bindRemoteClaudeInterruptReconciliation( - agentHookServer, - mux, - this.targetId, - () => this.mux === mux - ) - this.muxNotificationCleanup = mux.onNotification((method, params) => { - if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { - return - } - const envelope = params - if ( - typeof envelope.paneKey !== 'string' || - (envelope.isReplay !== undefined && typeof envelope.isReplay !== 'boolean') || - (envelope.launchToken !== undefined && typeof envelope.launchToken !== 'string') - ) { - return - } - // Why: forward the agent CLI's env/version verbatim (not the relay's) so warn-once protocol-mismatch diagnostics fire for remote events too. - agentHookServer.ingestRemote( - { - paneKey: envelope.paneKey, - hostTurnRevision: envelope.hostTurnRevision, - launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, - tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, - worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, - env: typeof envelope.env === 'string' ? envelope.env : undefined, - version: typeof envelope.version === 'string' ? envelope.version : undefined, - hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, - promptInteractionKey: - typeof envelope.promptInteractionKey === 'string' - ? envelope.promptInteractionKey - : undefined, - hookEventName: - typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined, - source: envelope.source, - providerPromptId: envelope.providerPromptId, - grokPromptBoundary: envelope.grokPromptBoundary === true ? true : undefined, - compactTrigger: envelope.compactTrigger, - toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined, - toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined, - teammateName: - typeof envelope.teammateName === 'string' ? envelope.teammateName : undefined, - toolAgentType: - typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined, - isReplay: envelope.isReplay === true ? true : undefined, - providerSession: envelope.providerSession, - providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, - // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. - shedFields: envelope.shedFields, - claudeRunningNonAgentTask: - typeof envelope.claudeRunningNonAgentTask === 'boolean' - ? envelope.claudeRunningNonAgentTask - : undefined, - // Why: the SSH relay protocol advertises no run-serving capability. - advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES, - evidenceAgeMs: envelope.evidenceAgeMs, - statusUnavailable: envelope.statusUnavailable, - payload: envelope.payload - }, - this.targetId - ) - }) - - // Why: request replay of cached paneKeys only after the handler is wired, so replayed events can't arrive before we subscribe. Best-effort. - void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => { - const code = (err as { code?: unknown })?.code - if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { - return - } - if (mux.isDisposed()) { - return - } - // Why: suppress the warn when a normal teardown rejects the in-flight request, so reconnect cycles aren't noisy. - if (mux.isDisposed()) { - return - } - console.warn( - `[ssh-relay-session] agent_hook.requestReplay failed for ${this.targetId}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - }) - } - - private prepareOpenCodeRuntimeForScan(): void { - const preparation = this.openCodeRuntimePreparation - if (preparation) { - void preparation.run(preparation.controller.signal) - } - } - - private teardownProviders( - reason: 'shutdown' | 'connection_lost', - outputGenerationReason: string = reason - ): void { - this.openCodeRuntimePreparation?.controller.abort() - this.openCodeRuntimePreparation = null - this.releaseRelayLossWatcher() - this.pluginSettingsCleanup?.() - this.pluginSettingsCleanup = null - this.pluginInstallGeneration += 1 - this.clearPluginInstallRetry() - this.leavePlainSshMode() - this.muxNotificationCleanup?.() - this.muxNotificationCleanup = null - this.remoteInterruptCleanup?.() - this.remoteInterruptCleanup = null - for (const cleanup of this.ptyRecoveryNotificationCleanups) { - cleanup() - } - this.ptyRecoveryNotificationCleanups = [] - if (this.activePtyProviderGeneration !== null) { - const providerGeneration = this.activePtyProviderGeneration - if (reason === 'connection_lost' && this.activePtyConsumerOwner()?.outputFlowControl) { - this.beginPtyModelMigration(providerGeneration, outputGenerationReason) - } else { - closeSshPtyOutputGeneration(providerGeneration, outputGenerationReason) - } - this.activePtyProviderGeneration = null - } - this.sourceAckPublisherCleanup?.() - this.sourceAckPublisherCleanup = null - this.sourceCancellationPublisherCleanup?.() - this.sourceCancellationPublisherCleanup = null - if (this.mux && !this.mux.isDisposed()) { - this.mux.dispose(reason) - } - this.mux = null - for (const attachmentId of this.activeCompatibilityAttachmentIds) { - this.runtime?.releaseOrchestrationCompatibilitySshAttachment(attachmentId) - } - this.activeCompatibilityAttachmentIds.clear() - - if (reason === 'shutdown') { - clearPtyOwnershipForConnection(this.targetId) - } - // Connection loss makes remote status unverifiable, not exited. Keep the last observation; - // replay or certified process teardown will update or remove it on the execution host. - - retireSshHostProviders(this.targetId) - this.sourceIdentityByRelayPtyId.clear() - this.retiredSourceDeliveries.clear() - this.rejectedPtyRecoveryAttempts.clear() - for (const timer of this.rejectedPtyRecoveryRetries) { - clearTimeout(timer) - } - this.rejectedPtyRecoveryRetries.clear() - this.rejectedPtyReattaches.clear() - this.ptyFrameRejectionLog.clear() - for (const pending of this.pendingPtyReattaches.values()) { - for (const resolve of pending.recoveryWaiters) { - resolve() - } - } - this.pendingPtyReattaches.clear() - this.ptyRecoveryRetention.clear() - } - - // Why: back-compat for old relays that gate FS ops on registered roots; removable post-cutover (docs/relay-fs-allowlist-removal.md). - private async registerRelayRoots(mux: SshChannelMultiplexer): Promise { - const remoteRepos = this.store.getRepos().filter((r) => r.connectionId === this.targetId) - - for (const repo of remoteRepos) { - mux.notify('session.registerRoot', { rootPath: repo.path }) - } - - // Why: git.listWorktrees requires the repo root to be registered first. - await Promise.all( - remoteRepos.map(async (repo) => { - try { - const worktrees = (await mux.request('git.listWorktrees', { - repoPath: repo.path - })) as { path: string }[] - for (const wt of worktrees) { - if (wt.path !== repo.path) { - mux.notify('session.registerRoot', { rootPath: wt.path }) - } - } - } catch { - // git worktree list may fail for folder-mode repos — not fatal - } - }) - ) - } - - // Why: shared by establish()/reconnect() so both paths reset renderer lists the same way. - private broadcastEmptyLists(): void { - const win = this.getMainWindow() - if (!win || win.isDestroyed()) { - return - } - win.webContents.send('ssh:port-forwards-changed', { - targetId: this.targetId, - forwards: [] - }) - win.webContents.send('ssh:detected-ports-changed', { - targetId: this.targetId, - ports: [] - }) - } - - private startPortScanning(): void { - if (!this.mux || this.isDisposed()) { - return - } - // Why: each scan walks /proc/*/fd remotely, so skip ticks while the window is hidden and rescan when it returns. - const scanner = new PortScanner({ - isWindowVisible: () => isMainWindowVisible(this.getMainWindow()), - onWindowBecameVisible: onMainWindowBecameVisible - }) - this.portScanner = scanner - // Why: guard against a late ports.detect callback from a pre-reconnect scanner publishing stale results into the new session. - scanner.startScanning(this.targetId, this.mux, (targetId, ports, platform) => { - if (this.portScanner !== scanner) { - return - } - this.onDetectedPortsChanged?.(targetId, ports, platform) - }) - } - - private stopPortScanning(): void { - if (this.portScanner) { - this.portScanner.stopScanning(this.targetId) - this.portScanner = null - } - } - - private wireUpPtyEvents( - ptyProvider: SshPtyProvider, - mux: SshChannelMultiplexer, - providerGeneration: number - ): void { - ptyProvider.onData((payload) => { - if ( - this.mux !== mux || - this.activePtyProviderGeneration !== providerGeneration || - payload.providerGeneration !== providerGeneration - ) { - return - } - const pending = this.pendingPtyReattaches.get(payload.id) - if (pending && this.activePtyConsumerOwner()?.outputFlowControl) { - this.quarantineReattachData(pending, payload) - return - } - void this.acceptPtyData(payload).catch(() => {}) - }) - ptyProvider.onRejectedData?.((payload) => { - if ( - this.mux !== mux || - this.activePtyProviderGeneration !== providerGeneration || - payload.providerGeneration !== providerGeneration - ) { - return - } - const pending = this.pendingPtyReattaches.get(payload.id) - if (pending) { - pending.restoreRequired = payload.sourceMalformed - ? 'recoverySourceMalformed' - : 'recoverySourceUnadmitted' - this.wakeRecovery(pending) - return - } - void this.acceptPtyData(payload).catch(() => {}) - }) - ptyProvider.onReplay((payload) => { - if (this.mux !== mux || this.activePtyProviderGeneration !== providerGeneration) { - return - } - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:replay', payload) - } - }) - ptyProvider.onExit((payload) => { - if ( - this.mux !== mux || - this.activePtyProviderGeneration !== providerGeneration || - payload.providerGeneration !== providerGeneration - ) { - return - } - const pendingReattach = this.pendingPtyReattaches.get(payload.id) - if (pendingReattach && !pendingReattach.activated) { - // Why: attach response and exit can share one transport batch, before incarnation restoration runs. - pendingReattach.exits.push(payload) - this.wakeRecovery(pendingReattach) - return - } - if (!isCurrentPtyExit(payload)) { - return - } - void this.acceptPtyExit(payload).catch(() => {}) - }) - } - - private acceptPtyData(payload: SshPtyDataPayload): Promise { - const consumerOwner = this.activePtyConsumerOwner() - const offeredSource = payload.source - if ( - offeredSource && - this.retiredSourceDeliveries.has(payload.providerGeneration, offeredSource) - ) { - return Promise.resolve() - } - const rejection = classifySshPtyFrameRejection(payload, consumerOwner) - if (rejection) { - if (offeredSource) { - this.retiredSourceDeliveries.retire(payload.providerGeneration, offeredSource) - } - this.ptyFrameRejectionLog.record(payload, consumerOwner, rejection) - if (rejection.action === 'retire-and-reattach-delivery') { - this.recoverRejectedPtyDelivery(payload, offeredSource) - } - return Promise.resolve() - } - const source = consumerOwner?.outputFlowControl ? offeredSource : undefined - if (source && consumerOwner) { - const current = this.sourceIdentityByRelayPtyId.get(source.relayPtyId) - if ( - source.sourceEndSu <= source.sourceStartSu || - (current && - (current.deliveryToken !== source.deliveryToken || - current.clientGeneration !== source.clientGeneration || - current.ownerGeneration !== source.ownerGeneration || - current.ptyIncarnation !== payload.ptyIncarnation || - (current.nextSourceSu !== undefined && current.nextSourceSu !== source.sourceStartSu))) - ) { - const rejection = { - reason: 'source-range-invalid', - action: 'retire-and-reattach-delivery' - } as const - this.retiredSourceDeliveries.retire(payload.providerGeneration, source) - this.ptyFrameRejectionLog.record(payload, consumerOwner, rejection) - this.recoverRejectedPtyDelivery(payload, source) - return Promise.resolve() - } - this.sourceIdentityByRelayPtyId.set(source.relayPtyId, { - deliveryToken: source.deliveryToken, - clientGeneration: source.clientGeneration, - ownerGeneration: source.ownerGeneration, - ptyIncarnation: payload.ptyIncarnation, - nextSourceSu: source.sourceEndSu - }) - } - const rawLength = payload.sequenceChars ?? payload.data.length - return acceptSshPtyOutputData({ - id: payload.id, - data: payload.data, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation, - rawLength, - transformed: payload.transformed === true, - ...(typeof payload.seq === 'number' ? { sequence: payload.seq } : {}), - ...(source ? { source } : {}) - }) - } - - private recoverRejectedPtyDelivery( - payload: SshPtyDataPayload, - source: SshPtyDataPayload['source'] - ): void { - const mux = this.mux - const providerGeneration = this.activePtyProviderGeneration - let relayPtyId: string - try { - relayPtyId = source?.relayPtyId ?? toRelaySshPtyId(this.targetId, payload.id) - } catch { - return - } - const appPtyId = toAppSshPtyId(this.targetId, relayPtyId) - if ( - payload.id !== appPtyId || - !mux || - mux.isDisposed() || - providerGeneration !== payload.providerGeneration || - this.pendingPtyReattaches.has(appPtyId) || - this.rejectedPtyReattaches.has(appPtyId) - ) { - return - } - if (payload.rejectedSourceRecovery === 'reconnect-channel') { - console.warn( - `[ssh-relay-session] PTY ${relayPtyId} delivery identity could not be retired safely for ${this.targetId}; dropping the relay channel to reconnect` - ) - mux.dispose('connection_lost') - return - } - const previous = this.rejectedPtyRecoveryAttempts.get(appPtyId) - const attempt = - previous?.providerGeneration === providerGeneration - ? previous - : { providerGeneration, attempts: 0, generationAttempts: 0, reported: false } - if ( - attempt.attempts >= SSH_REJECTED_PTY_RECOVERY_MAX_ATTEMPTS || - attempt.generationAttempts >= SSH_REJECTED_PTY_RECOVERY_MAX_GENERATION_ATTEMPTS - ) { - if (!attempt.reported) { - attempt.reported = true - console.warn( - `[ssh-relay-session] PTY ${relayPtyId} delivery recovery exhausted for ${this.targetId}; dropping the relay channel to reconnect` - ) - // Why a channel drop and not a terminal relay error: a terminal error clears the reconnect - // backoff, rotates provider authority (aborting every in-flight fs and git request on the - // target) and parks the target in a manual-recovery state — over one PTY's delivery. Losing - // the channel is the recoverable escalation, and it is what this path did before targeted - // recovery existed. - mux.dispose('connection_lost') - } - return - } - attempt.attempts++ - attempt.generationAttempts++ - this.rejectedPtyRecoveryAttempts.set(appPtyId, attempt) - void this.rejectedPtyReattaches - .run(appPtyId, () => - this.reattachRejectedPty( - relayPtyId, - mux, - providerGeneration, - payload.rejectedSourceRecovery === 'fresh-activation' - ? 'fresh-activation' - : 'confirm-existing' - ) - ) - .then( - (recovered) => { - if (recovered) { - // Why only a completed reattach clears this: an accepted frame proves nothing about the - // delivery that was rejected, and resetting on one lets a flapping PTY reattach forever. - attempt.attempts = 0 - return - } - this.retryRejectedPtyDelivery(payload, source, appPtyId) - }, - (error: unknown) => { - console.warn(`[ssh-relay-session] PTY ${relayPtyId} targeted delivery recovery failed`, { - providerGeneration, - error: error instanceof Error ? error.message : String(error) - }) - this.retryRejectedPtyDelivery(payload, source, appPtyId) - } - ) - } - - // Why liveness is checked before retrying: reattachKnownPty resolves without claiming the lease - // when the PTY exited mid-attach, which is indistinguishable from a failed reattach at the call - // site. Retrying that race twice would drop the relay channel over an ordinary PTY exit. - private retryRejectedPtyDelivery( - payload: SshPtyDataPayload, - source: SshPtyDataPayload['source'], - appPtyId: string - ): void { - const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - if (!ptyProvider || typeof ptyProvider.hasPty !== 'function' || !ptyProvider.hasPty(appPtyId)) { - this.rejectedPtyRecoveryAttempts.delete(appPtyId) - return - } - const timer = setTimeout(() => { - this.rejectedPtyRecoveryRetries.delete(timer) - this.recoverRejectedPtyDelivery(payload, source) - }, SSH_REJECTED_PTY_RECOVERY_RETRY_DELAY_MS) - timer.unref?.() - this.rejectedPtyRecoveryRetries.add(timer) - } - - private async reattachRejectedPty( - relayPtyId: string, - mux: SshChannelMultiplexer, - providerGeneration: number, - targetedDeliveryRecovery: TargetedDeliveryRecovery - ): Promise { - const shouldContinue = () => - this.mux === mux && - !mux.isDisposed() && - this.activePtyProviderGeneration === providerGeneration - const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - // Why re-checked here: this can have waited for a queue slot, and a superseded generation must - // not pay for a lease read or an attach round trip. - if (!ptyProvider || !shouldContinue()) { - return false - } - const activeLease = this.store - .getSshRemotePtyLeases(this.targetId) - .find((lease) => lease.ptyId === relayPtyId && sshRemotePtyLeaseAllowsReattach(lease)) - const activeLeaseByPtyId = activeLease - ? new Map([[relayPtyId, activeLease]]) - : new Map() - const expectedIdentity = activeLease ? expectedIdentityForLease(activeLease) : undefined - const attachedLeaseIds = new Set() - await this.reattachKnownPty({ - ptyProvider, - ptyId: relayPtyId, - activeLeaseByPtyId, - expectedIdentityByPtyId: expectedIdentity - ? new Map([[relayPtyId, expectedIdentity]]) - : new Map(), - attachedLeaseIds, - mux, - providerGeneration, - shouldContinue, - targetedDeliveryRecovery - }) - if (attachedLeaseIds.size > 0 && shouldContinue()) { - await this.store.markSshRemotePtyLeasesAttachedAsync( - this.targetId, - Array.from(attachedLeaseIds) - ) - } - return attachedLeaseIds.has(relayPtyId) - } - - private quarantineReattachData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { - if (pending.livePassthrough) { - if (this.ownsPtyRecoveryAttempt(payload.id, pending)) { - void this.acceptPtyData(payload).catch(() => {}) - } - return - } - this.observePrivateRecoveryFrame(pending, payload) - if (pending.restoreRequired) { - return - } - const sourceSu = payload.source - ? payload.source.sourceEndSu - payload.source.sourceStartSu - : (payload.sequenceChars ?? payload.data.length) - if (!this.ptyRecoveryRetention.tryRetain(pending.retentionKey, payload.data, sourceSu)) { - pending.restoreRequired = 'recoveryQuarantineCapacityExceeded' - this.wakeRecovery(pending) - return - } - this.routeQuarantinedReattachData(pending, payload) - } - - private routeQuarantinedReattachData( - pending: PendingPtyReattach, - payload: SshPtyDataPayload - ): void { - this.observePrivateRecoveryFrame(pending, payload) - if (!pending.recovery) { - pending.queuedData.push(payload) - return - } - if ( - pending.recoveryComplete && - pending.nextRecoverySourceSu === pending.recovery.recoveryEndSu - ) { - pending.liveData.push(payload) - return - } - this.admitRecoveryData(pending, payload) - } - - private observePrivateRecoveryFrame( - pending: PendingPtyReattach, - payload: SshPtyDataPayload - ): void { - const recovery = pending.recovery - if ( - recovery && - payload.source?.deliveryToken === recovery.deliveryToken && - payload.source.clientGeneration === recovery.clientGeneration && - payload.source.ownerGeneration === recovery.ownerGeneration && - payload.ptyIncarnation === recovery.ptyIncarnation - ) { - pending.highestRecoverySourceEndSu = Math.max( - pending.highestRecoverySourceEndSu ?? recovery.checkpointSourceEndSu, - payload.source.sourceEndSu - ) - } - } - - private admitRecoveryData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { - if (pending.restoreRequired) { - return - } - const recovery = pending.recovery - const nextSourceSu = pending.nextRecoverySourceSu - if ( - !recovery || - !payload.source || - nextSourceSu === undefined || - payload.source.deliveryToken !== recovery.deliveryToken || - payload.source.clientGeneration !== recovery.clientGeneration || - payload.source.ownerGeneration !== recovery.ownerGeneration || - payload.source.sourceStartSu !== nextSourceSu || - payload.source.sourceEndSu <= payload.source.sourceStartSu || - payload.source.sourceEndSu > recovery.recoveryEndSu || - payload.ptyIncarnation !== recovery.ptyIncarnation - ) { - pending.restoreRequired = 'recoveryFrameIdentityMismatch' - this.wakeRecovery(pending) - return - } - pending.nextRecoverySourceSu = payload.source.sourceEndSu - pending.recoveryData.push(payload) - } - - private installPtyRecoveryNotifications(mux: SshChannelMultiplexer): void { - for (const cleanup of this.ptyRecoveryNotificationCleanups) { - cleanup() - } - this.ptyRecoveryNotificationCleanups = [ - mux.onNotificationByMethod('pty.recoveryComplete', (params) => { - if (this.mux !== mux) { - return - } - const id = typeof params.id === 'string' ? toAppSshPtyId(this.targetId, params.id) : '' - const pending = this.pendingPtyReattaches.get(id) - if (!pending || pending.mux !== mux) { - return - } - const complete = parseRecoveryComplete(params) - if (!complete) { - pending.restoreRequired = 'invalidRecoveryComplete' - } else { - pending.recoveryComplete = complete - } - this.wakeRecovery(pending) - }), - mux.onNotificationByMethod('pty.restoreRequired', (params) => { - if (this.mux !== mux) { - return - } - const id = typeof params.id === 'string' ? toAppSshPtyId(this.targetId, params.id) : '' - const pending = this.pendingPtyReattaches.get(id) - if (!pending || pending.mux !== mux) { - return - } - pending.restoreRequired = - typeof params.reason === 'string' ? params.reason : 'relayRestoreRequired' - this.wakeRecovery(pending) - }), - mux.onNotificationByMethod('pty.deliveryCanceled', (params) => { - if (this.mux !== mux) { - return - } - const id = typeof params.id === 'string' ? params.id : '' - const identity = this.sourceIdentityByRelayPtyId.get(id) - if ( - !identity || - params.deliveryToken !== identity.deliveryToken || - params.clientGeneration !== identity.clientGeneration || - params.ownerGeneration !== identity.ownerGeneration || - params.ptyIncarnation !== identity.ptyIncarnation - ) { - return - } - const replacementDeliveryToken = - typeof params.replacementDeliveryToken === 'string' ? params.replacementDeliveryToken : '' - const pending = this.pendingPtyReattaches.get(toAppSshPtyId(this.targetId, id)) - if (pending?.mux === mux) { - if ( - replacementDeliveryToken.length === 0 || - replacementDeliveryToken === identity.deliveryToken - ) { - pending.restoreRequired = - typeof params.reason === 'string' - ? `relayDeliveryCanceled:${params.reason}` - : 'relayDeliveryCanceled' - this.wakeRecovery(pending) - return - } - if ( - pending.replacementDeliveryToken && - pending.replacementDeliveryToken !== replacementDeliveryToken - ) { - pending.restoreRequired = 'recoveryReplacementTokenMismatch' - this.wakeRecovery(pending) - return - } - pending.replacementDeliveryToken = replacementDeliveryToken - return - } - const generation = this.activePtyProviderGeneration - if ( - generation !== null && - Number.isSafeInteger(params.sentEndSu) && - Number.isSafeInteger(params.creditedEndSu) - ) { - try { - applySshPtySourceCancellationProof( - { - id: toAppSshPtyId(this.targetId, id), - code: -1, - providerGeneration: generation, - ptyIncarnation: identity.ptyIncarnation - }, - { - sentEndSu: params.sentEndSu as number, - creditedEndSu: params.creditedEndSu as number - } - ) - this.retiredSourceDeliveries.retire(generation, { - relayPtyId: id, - ...identity - }) - this.sourceIdentityByRelayPtyId.delete(id) - } catch { - /* Invalid proof retains the active token identity. */ - } - } - }) - ] - } - - private wakeRecovery(pending: PendingPtyReattach): void { - for (const resolve of pending.recoveryWaiters) { - resolve() - } - pending.recoveryWaiters.clear() - } - - private async acceptPtyExit(payload: SshPtyExitPayload): Promise { - await acceptSshPtyOutputExit({ - id: payload.id, - code: payload.code, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation - }) - if (isCurrentPtyExit(payload)) { - this.retireExitedPty(payload, true) - } - } - - private retireExitedPty(payload: SshPtyExitPayload, deliveryHandled = false): void { - const relayPtyId = toRelaySshPtyId(this.targetId, payload.id) - this.retiredSourceDeliveries.activate(relayPtyId) - clearProviderPtyState(payload.id) - deletePtyOwnership(payload.id) - this.rejectedPtyRecoveryAttempts.delete(payload.id) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(payload.id) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete( - toRelaySshPtyId(this.targetId, payload.id) - ) - this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') - if (deliveryHandled) { - return - } - this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId) - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:exit', payload) - } - } - - private forwardReattachReplay(appPtyId: string, data: string): void { - if (!data) { - return - } - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:replay', { id: appPtyId, data }) - } - } - - private async reattachKnownPtys( - mux: SshChannelMultiplexer, - shouldContinue: () => boolean - ): Promise { - const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - const providerGeneration = this.activePtyProviderGeneration - if (!ptyProvider || providerGeneration === null || this.mux !== mux) { - return - } - // Why before the lease read: a stop the user asked for and this client could not deliver is - // replayed here, and a confirmed one tombstones its lease — so it must land before the filter - // below decides what to reattach, or the reattach revives a PTY that is about to die. - await replayPendingSshPtyKills({ - targetId: this.targetId, - store: this.store, - provider: ptyProvider, - shouldContinue - }) - if (!shouldContinue()) { - return - } - // Why immediately before the read: a pane's binding is written by several writers, and the - // renderer's debounced layout publish lands long after the spawn commit that leased the pty — - // so a predecessor that was still bound at spawn time never gets marked by a spawn-side - // trigger. Re-deriving from each pane's CURRENT binding here is what actually bounds this set, - // and it repairs stores that already accumulated these rows. - this.store.reconcileSshRemotePtyLeasesForTarget(this.targetId) - // Why not `state !== 'expired'`: that state covers both a superseded sibling (re-adopting it is - // the 2 -> 19 -> 20 fan-out) and an orphan whose reattach merely lost contact. Only the first - // carries a retirement mark, and only it has to be skipped. - const activeLeases = this.store - .getSshRemotePtyLeases(this.targetId) - .filter((lease) => sshRemotePtyLeaseAllowsReattach(lease)) - const activeLeaseByPtyId = new Map(activeLeases.map((lease) => [lease.ptyId, lease])) - const leasedPtyIds = activeLeases.map((lease) => lease.ptyId) - // Why: pass pane identity so the relay can reject cross-generation id collisions; tabId falls back for pre-leafId leases. - const expectedIdentityByPtyId = new Map( - activeLeases - .map((lease): [string, ExpectedPtyIdentity] | null => { - const expected = expectedIdentityForLease(lease) - return expected ? [lease.ptyId, expected] : null - }) - .filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null) - ) - const attachedLeaseIds = new Set() - // Why: after app restart ptyOwnership is empty, but durable SSH leases still describe grace-window survivors. - const ptyIds = Array.from( - new Set([ - ...getPtyIdsForConnection(this.targetId).map((ptyId) => - toRelaySshPtyId(this.targetId, ptyId) - ), - ...leasedPtyIds - ]) - ) - let nextPtyIndex = 0 - const worker = async (): Promise => { - while (shouldContinue()) { - const ptyId = ptyIds[nextPtyIndex++] - if (ptyId === undefined) { - return - } - try { - await this.reattachKnownPty({ - ptyProvider, - ptyId, - activeLeaseByPtyId, - expectedIdentityByPtyId, - attachedLeaseIds, - mux, - providerGeneration, - shouldContinue - }) - } catch (error) { - if (isSourceRecoveryCancellationError(error)) { - throw error - } - console.warn( - `[ssh-relay-session] PTY ${ptyId} reattach processing failed for ${this.targetId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - } - } - } - await Promise.all( - Array.from({ length: Math.min(SSH_PTY_REATTACH_MAX_CONCURRENCY, ptyIds.length) }, worker) - ) - if (attachedLeaseIds.size > 0 && shouldContinue()) { - await this.store.markSshRemotePtyLeasesAttachedAsync( - this.targetId, - Array.from(attachedLeaseIds) - ) - } - // Why last: reclaiming comes first, so every PTY this connect could route to is routed before - // anything asks which ones are unreachable (#9819). - await sweepOrphanedRelayPtys({ - targetId: this.targetId, - store: this.store, - provider: ptyProvider, - clientInstanceId: this.ptyConsumerClientInstanceId, - isSessionOwner: this.activePtyConsumerOwner() !== null, - routedPtyIds: ptyIds, - shouldContinue - }) - } - - private async reattachKnownPty(args: { - ptyProvider: SshPtyProvider - ptyId: string - activeLeaseByPtyId: Map - expectedIdentityByPtyId: Map - attachedLeaseIds: Set - mux: SshChannelMultiplexer - providerGeneration: number - shouldContinue: () => boolean - targetedDeliveryRecovery?: TargetedDeliveryRecovery - }): Promise { - const { - ptyProvider, - ptyId, - activeLeaseByPtyId, - expectedIdentityByPtyId, - attachedLeaseIds, - mux, - providerGeneration, - shouldContinue, - targetedDeliveryRecovery - } = args - const appPtyId = toAppSshPtyId(this.targetId, ptyId) - const pendingReattach: PendingPtyReattach = { - mux, - providerGeneration, - retentionKey: `${providerGeneration}\0${appPtyId}\0${randomUUID()}`, - exits: [], - queuedData: [], - recoveryData: [], - liveData: [], - recoveryWaiters: new Set(), - livePassthrough: false, - activated: false - } - this.pendingPtyReattaches.set(appPtyId, pendingReattach) - let sourceActivationLease: SshPtyAttachResult['sourceActivationLease'] - let recoveryActivationLease: SshPtyRecoveryActivationLease | undefined - try { - const recoveryRequest = - targetedDeliveryRecovery === 'fresh-activation' - ? undefined - : await this.sourceRecoveryRequest(appPtyId) - const attachResult = await this.attachPtyWithRetry( - ptyProvider, - ptyId, - expectedIdentityByPtyId.get(ptyId), - recoveryRequest, - shouldContinue - ) - sourceActivationLease = attachResult.sourceActivationLease - if (!shouldContinue()) { - return - } - const exitDuringAttach = pendingReattach.exits.find( - (exit) => - !exit.incarnationId || - !attachResult.incarnationId || - exit.incarnationId === attachResult.incarnationId - ) - if (exitDuringAttach && !recoveryRequest) { - if (attachResult.incarnationId) { - restorePtyIncarnation(appPtyId, attachResult.incarnationId) - this.runtime?.acceptPtyIncarnationForExit(appPtyId, attachResult.incarnationId) - } - await this.acceptPtyExit(exitDuringAttach) - return - } - const existingDeliveryConfirmed = - targetedDeliveryRecovery === 'confirm-existing' && - recoveryRequest?.status === 'checkpoint' && - !attachResult.sourceRecovery && - Boolean( - attachResult.sourceActivation && - this.sameSourceDelivery(attachResult.sourceActivation, recoveryRequest) - ) - if (targetedDeliveryRecovery) { - const owner = this.activePtyConsumerOwner() - const activation = attachResult.sourceActivation - if ( - !owner?.outputFlowControl || - !activation || - activation.clientGeneration !== owner.clientGeneration || - activation.ownerGeneration !== owner.ownerGeneration - ) { - return - } - } - if (recoveryRequest && !existingDeliveryConfirmed) { - const recovered = await this.finishSourceRecovery( - ptyId, - appPtyId, - attachResult, - recoveryRequest, - pendingReattach, - shouldContinue, - () => { - const lease = sourceActivationLease - if (!lease) { - return - } - recoveryActivationLease = lease.transferToRecovery((payload) => - this.quarantineReattachData(pendingReattach, payload) - ) - sourceActivationLease = undefined - } - ) - if (!recovered) { - const recoveryExit = this.findExactPendingExit( - pendingReattach, - attachResult.incarnationId - ) - if ( - recoveryExit && - shouldContinue() && - this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach) - ) { - if (recoveryActivationLease) { - recoveryActivationLease.retire() - recoveryActivationLease = undefined - } else if (sourceActivationLease) { - const canceled = await sourceActivationLease.rollback() - sourceActivationLease = undefined - if (!canceled) { - throw sourceRecoveryCancellationError( - new Error('ssh_source_activation_cancellation_unproven') - ) - } - } - this.preparePtyIncarnationForExit(appPtyId, attachResult.incarnationId) - await this.acceptPtyExit(recoveryExit) - } - return - } - const recoveryExit = this.findExactPendingExit(pendingReattach, attachResult.incarnationId) - if (recoveryExit) { - this.preparePtyIncarnationForExit(appPtyId, attachResult.incarnationId) - pendingReattach.activated = true - recoveryActivationLease?.commit() - recoveryActivationLease = undefined - await this.acceptPtyExit(recoveryExit) - return - } - } - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) { - return - } - const activeLease = activeLeaseByPtyId.get(ptyId) - if (this.isRetiredReattachedPtySurface(activeLease, appPtyId, attachResult.incarnationId)) { - await this.suppressRetiredReattachedPty( - ptyProvider, - ptyId, - appPtyId, - attachResult.incarnationId - ) - return - } - if (attachResult.incarnationId) { - const restoreResult = await this.restoreReattachedPtyRuntime( - appPtyId, - attachResult.incarnationId, - activeLease, - () => shouldContinue() && this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach) - ) - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) { - return - } - if (restoreResult !== 'restored') { - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - return - } - } else { - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - } - attachedLeaseIds.add(ptyId) - pendingReattach.activated = true - recoveryActivationLease?.commit() - recoveryActivationLease = undefined - if (targetedDeliveryRecovery) { - if (targetedDeliveryRecovery === 'fresh-activation') { - this.retiredSourceDeliveries.activate(ptyId) - this.sourceIdentityByRelayPtyId.delete(ptyId) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(appPtyId) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(ptyId) - } - while (pendingReattach.queuedData.length > 0) { - await this.acceptPtyData(pendingReattach.queuedData.shift()!) - } - pendingReattach.livePassthrough = true - } - const exitAfterActivation = pendingReattach.exits.find( - (exit) => - !exit.incarnationId || - !attachResult.incarnationId || - exit.incarnationId === attachResult.incarnationId - ) - if (exitAfterActivation) { - await this.acceptPtyExit(exitAfterActivation) - return - } - if (!recoveryRequest && !targetedDeliveryRecovery) { - this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') - } - sourceActivationLease?.commit() - sourceActivationLease = undefined - } catch (error) { - if (isSourceRecoveryCancellationError(error)) { - throw error - } - if (!shouldContinue()) { - return - } - if (await isReattachHeldByPreviousRelay(this.targetId, error)) { - const routed = await this.reattachThroughPreviousRelay({ - ptyProvider, - ptyId, - appPtyId, - expected: expectedIdentityByPtyId.get(ptyId), - lease: activeLeaseByPtyId.get(ptyId), - attachedLeaseIds, - shouldContinue - }) - if (!routed) { - // Same as an exhausted reattach: the PTY is unverifiable, kept, and left for recovery. - pendingReattach.restoreRequired = 'reattachAttemptsExhausted' - this.wakeRecovery(pendingReattach) - console.warn( - `[ssh-relay-session] Keeping PTY ${ptyId} for ${this.targetId}: an older Orca relay on this host may still run it` - ) - } - return - } - if (!shouldContinue()) { - return - } - this.handlePtyReattachFailure(ptyId, appPtyId, pendingReattach, error) - } finally { - recoveryActivationLease?.retire() - sourceActivationLease?.rollback() - if (this.pendingPtyReattaches.get(appPtyId) === pendingReattach) { - this.pendingPtyReattaches.delete(appPtyId) - } - this.ptyRecoveryRetention.release(pendingReattach.retentionKey) - } - } - - private findExactPendingExit( - pending: PendingPtyReattach, - ptyIncarnation: string | undefined - ): SshPtyExitPayload | undefined { - if (!ptyIncarnation) { - return undefined - } - return pending.exits.find( - (exit) => - exit.providerGeneration === pending.providerGeneration && - exit.ptyIncarnation === ptyIncarnation - ) - } - - private preparePtyIncarnationForExit(appPtyId: string, ptyIncarnation: string | undefined): void { - if (!ptyIncarnation) { - return - } - restorePtyIncarnation(appPtyId, ptyIncarnation) - this.runtime?.acceptPtyIncarnationForExit(appPtyId, ptyIncarnation) - } - - private isRetiredReattachedPtySurface( - lease: SshPtyLease | undefined, - appPtyId: string, - incarnationId: string | undefined - ): boolean { - if (!lease?.worktreeId || !lease.tabId || !lease.leafId || !isTerminalLeafId(lease.leafId)) { - return false - } - const leafId = lease.leafId - const session = this.store.getWorkspaceSession?.() - const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) - const candidates = [session, hostSession] - const currentTabIds = candidates - .map((candidate) => findTerminalTabIdForLeaf(candidate, leafId)) - .filter((tabId): tabId is string => Boolean(tabId && isValidTerminalTabId(tabId))) - const tombstoneMatches = (tabId: string): boolean => { - const paneKey = makePaneKey(tabId, leafId) - return candidates.some((candidate) => { - const tombstone = candidate?.terminalSurfaceTombstonesByPaneKey?.[paneKey] - return Boolean( - tombstone?.ptyId === appPtyId && - (!incarnationId || tombstone.incarnationId === incarnationId) - ) - }) - } - const hasLiveCurrentBinding = currentTabIds.some((tabId) => { - const paneKey = makePaneKey(tabId, leafId) - return ( - !tombstoneMatches(tabId) && - candidates.some( - (candidate) => - candidate?.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId?.[leafId] === appPtyId && - (!incarnationId || - !candidate.terminalPtyIncarnationsByPaneKey?.[paneKey] || - candidate.terminalPtyIncarnationsByPaneKey[paneKey] === incarnationId) - ) - ) - }) - if (hasLiveCurrentBinding) { - return false - } - // Why only when no tab holds the leaf: a pane moved out of the tab before it closed lives on. - const leaseTabId = lease.tabId - const closedByRecord = - currentTabIds.length === 0 && - candidates.some((candidate) => - hasClosedTerminalTabRecord(candidate?.closedTerminalTabTombstonesByTabId, leaseTabId) - ) - return ( - closedByRecord || - [lease.tabId, ...currentTabIds] - .filter((tabId) => isValidTerminalTabId(tabId)) - .some(tombstoneMatches) - ) - } - - private async suppressRetiredReattachedPty( - ptyProvider: SshPtyProvider, - relayPtyId: string, - appPtyId: string, - incarnationId: string | undefined - ): Promise { - try { - this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired') - } catch (error) { - console.error('[ssh-relay-session] Failed to expire retired PTY lease:', error) - } - if (incarnationId) { - try { - this.store.recordSshRemotePtyKillIntent(this.targetId, relayPtyId, { - requestedAt: Date.now(), - incarnationId, - attempts: 0 - }) - } catch (error) { - console.error('[ssh-relay-session] Failed to persist retired PTY stop:', error) - } - try { - await ptyProvider.shutdown(appPtyId, { - immediate: true, - expectedIncarnationId: incarnationId - }) - } catch (error) { - console.warn( - '[ssh-relay-session] Retired PTY stop is unverifiable and remains pending:', - error - ) - } - } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - } - - private async restoreReattachedPtyRuntime( - appPtyId: string, - incarnationId: string, - lease: SshPtyLease | undefined, - shouldContinue: () => boolean - ): Promise { - if (lease?.worktreeId && lease.tabId && lease.leafId) { - const { worktreeId, leafId, tabId: leaseTabId } = lease - let tabId = lease.tabId - // The pane's home is this target's partition, where its spawn bound it. Binding into `local` - // left a copy there that startup then preferred over the home copy (STA-9544). - const hostId = toSshExecutionHostId(this.targetId) - const bound = await this.store.persistPtyBinding(() => { - if (!shouldContinue()) { - return null - } - const session = this.store.getWorkspaceSession?.(hostId) - // Rows an older build left in `local`; the renderer moves them home on its next save. - const legacySession = this.store.getWorkspaceSession?.() - // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so - // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that - // merely moved. Leaf is the identity, the tab is only where it currently sits. - tabId = - findTerminalTabIdForLeaf(session, leafId) ?? - findTerminalTabIdForLeaf(legacySession, leafId) ?? - leaseTabId - // Absence of the pane only means "the user closed it" once the persisted membership - // speaks for this worktree. Before that it means the renderer has not published its - // layout yet, and refusing there drops a tab the user still has — the regression that - // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an - // unauthoritative session still gets the creating write. - // A pane only `local` still holds is not gone either: refusing it would strand a live pane - // behind a binding reattach can no longer reach. - const mayCreate = - !hasHostAuthoritativeTerminalMembership(session, worktreeId) || - findTerminalTabIdForLeaf(legacySession, leafId) !== undefined - return { - worktreeId: worktreeId, - tabId, - leafId: leafId, - ptyId: appPtyId, - incarnationId, - ...(mayCreate ? {} : { mayCreate: false }), - mayReviveRetiredSurface: false, - origin: 'relay_reattach' as const - } - }, hostId) - if (!shouldContinue()) { - return 'missing-surface' - } - if (bound === false) { - // Topology absence alone is not authority to kill a process, but neither refusal may - // publish or replay into a missing pane. - // We only got here because pty.attach succeeded, so the host just proved this PTY alive. - // Record that before the lease write: `expired` reads downstream as "reattach gave up", - // and terminal.recoverPane would otherwise treat this refusal as licence to spawn a - // replacement shell over a process the host attested is still running. - this.runtime?.markPtyLivenessLive(appPtyId) - this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired') - return 'missing-surface' - } - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - restorePtyIncarnation(appPtyId, incarnationId) - this.runtime?.registerPty(appPtyId, lease.worktreeId, this.targetId, { - tabId, - leafId: lease.leafId, - incarnationId - }) - return 'restored' - } - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - restorePtyIncarnation(appPtyId, incarnationId) - this.runtime?.onPtySpawned(appPtyId, incarnationId, { awaitsRegistration: false }) - return 'restored' - } - - /** True when an older relay now serves the PTY, or the attempt stopped being current. */ - private async reattachThroughPreviousRelay(args: { - ptyProvider: SshPtyProvider - ptyId: string - appPtyId: string - expected: ExpectedPtyIdentity | undefined - lease: SshPtyLease | undefined - attachedLeaseIds: Set - shouldContinue: () => boolean - }): Promise { - const { appPtyId, shouldContinue } = args - let result: SshPtyAttachResult | null - try { - result = await attachHeldPtyThroughPreviousRelay(args.ptyProvider, appPtyId, args.expected) - } catch (error) { - console.warn( - `[ssh-relay-session] Previous relay reattach failed for ${args.ptyId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return false - } - if (!result || !shouldContinue()) { - return result !== null - } - if (result.incarnationId) { - const restored = await this.restoreReattachedPtyRuntime( - appPtyId, - result.incarnationId, - args.lease, - shouldContinue - ) - if (restored !== 'restored') { - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - return true - } - } else { - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - } - args.attachedLeaseIds.add(args.ptyId) - this.forwardReattachReplay(appPtyId, result.replay ?? '') - return true - } - - private async attachPtyWithRetry( - ptyProvider: SshPtyProvider, - ptyId: string, - expectedIdentity: ExpectedPtyIdentity | undefined, - recoveryRequest: PtySourceRecoveryRequest | undefined, - shouldContinue: () => boolean - ): Promise { - let lastError: unknown - for (let attempt = 0; attempt < 2; attempt++) { - if (!shouldContinue()) { - throw lastError ?? new Error('PTY reattach attempt is no longer current') - } - try { - return await this.attachPtyWithDeadline( - ptyProvider, - ptyId, - expectedIdentity, - recoveryRequest - ) - } catch (error) { - lastError = error - if (!shouldContinue() || isSshPtyNotFoundError(error) || attempt === 1) { - throw error - } - await this.waitForPtyReattachRetry() - } - } - throw lastError - } - - private async attachPtyWithDeadline( - ptyProvider: SshPtyProvider, - ptyId: string, - expectedIdentity: ExpectedPtyIdentity | undefined, - recoveryRequest: PtySourceRecoveryRequest | undefined - ): Promise { - let timer: ReturnType | undefined - let timedOut = false - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - timedOut = true - reject( - new Error(`PTY reattach attempt timed out after ${SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS}ms`) - ) - }, SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS) - timer.unref?.() - }) - try { - const attach = expectedIdentity - ? recoveryRequest - ? ptyProvider.attachForReconnect(ptyId, expectedIdentity, recoveryRequest) - : ptyProvider.attachForReconnect(ptyId, expectedIdentity) - : recoveryRequest - ? ptyProvider.attachForReconnect(ptyId, undefined, recoveryRequest) - : ptyProvider.attachForReconnect(ptyId) - const guardedAttach = attach.then((result) => { - if (timedOut) { - result.sourceActivationLease?.rollback() - } - return result - }) - return (await Promise.race([guardedAttach, timeout])) ?? {} - } finally { - if (timer) { - clearTimeout(timer) - } - } - } - - private async waitForPtyReattachRetry(): Promise { - const delayMs = - SSH_PTY_REATTACH_RETRY_MIN_DELAY_MS + - Math.floor(Math.random() * (SSH_PTY_REATTACH_RETRY_JITTER_MS + 1)) - await new Promise((resolve) => { - const timer = setTimeout(resolve, delayMs) - timer.unref?.() - }) - } - - private handlePtyReattachFailure( - ptyId: string, - appPtyId: string, - pending: PendingPtyReattach, - error: unknown - ): void { - if (!isSshPtyNotFoundError(error)) { - pending.restoreRequired = 'reattachAttemptsExhausted' - this.wakeRecovery(pending) - console.warn( - `[ssh-relay-session] Leaving PTY ${ptyId} detached for ${this.targetId} after bounded reattach attempts failed: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return - } - if (isSshPtyIdentityMismatchError(error)) { - console.warn( - `[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return - } - console.warn( - `[ssh-relay-session] Dropping stale PTY ${ptyId} for ${this.targetId} after relay reattach failed: ${ - error instanceof Error ? error.message : String(error) - }` - ) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - // Deliberately does NOT call runtime.onPtyExit: pty.attach answers not-found both when it - // verified the pid is dead and when its session map simply has no such id (no liveness check on - // that path at all) — which is every id after a relay restart, since ids carry a per-start - // `ptyIdMintEpoch`. This branch may release the id, but certifying a death from that union - // would orphan a live remote shell (docs/reference/ssh-execution-boundary.md). The renderer - // gets code -1, which every reader treats as unverified loss. - this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - // Why a separate flag and not the code: `-1` is the stop sentinel every reader resolves to - // `stop_unverified`, so this branch — the one place a reachable relay answered for this exact - // id and reported it absent — was indistinguishable from a lost link. It says only that the - // relay disowned the id, which a restarted relay also does for ids it never minted, so it is - // deliberately not the `exited` verdict (docs/reference/ssh-execution-boundary.md). - win.webContents.send('pty:exit', { id: appPtyId, code: -1, ptySourceDisowned: true }) - } - } - - private async sourceRecoveryRequest( - appPtyId: string - ): Promise { - if (!this.activePtyConsumerOwner()?.outputFlowControl) { - return undefined - } - const recovery = getSshPtyConsumerRecovery(this.targetId) - const migration = recovery?.modelMigrationsByAppPtyId.get(appPtyId) - if (migration) { - const outcome = await migration - if (recovery?.modelMigrationsByAppPtyId.get(appPtyId) === migration) { - recovery.modelMigrationsByAppPtyId.delete(appPtyId) - } - if (outcome.status !== 'settled') { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - } - const checkpoints = recovery?.checkpointsByAppPtyId - const relayPtyId = toRelaySshPtyId(this.targetId, appPtyId) - // Why: every checkpoint writer records app-id keys now, so the relay-id - // lookup (and its paired delete below) is a legacy guard only. - const checkpoint = checkpoints?.get(appPtyId) ?? checkpoints?.get(relayPtyId) - if (!checkpoint) { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - return Object.freeze({ - status: 'checkpoint', - clientGeneration: checkpoint.clientGeneration, - ownerGeneration: checkpoint.ownerGeneration, - ptyIncarnation: checkpoint.ptyIncarnation, - deliveryToken: checkpoint.deliveryToken, - acceptedSourceEndSu: checkpoint.acceptedSourceEndSu - }) - } - - private beginPtyModelMigration(providerGeneration: number, closeReason: string): void { - const recovery = getSshPtyConsumerRecovery(this.targetId) - if (!recovery) { - closeSshPtyOutputGeneration(providerGeneration, closeReason) - return - } - for (const checkpoint of getSshPtyAcceptedSourceCheckpoints(providerGeneration)) { - recovery.checkpointsByAppPtyId.set(checkpoint.id, checkpoint) - } - const migration = beginSshPtyOutputGenerationMigration(providerGeneration) - for (const [ptyId, result] of migration.byPty) { - const previous = recovery.modelMigrationsByAppPtyId.get(ptyId) - const fence = previous ? previous.then(() => result) : result - recovery.modelMigrationsByAppPtyId.set(ptyId, fence) - void fence.then((outcome) => { - const current = getSshPtyConsumerRecovery(this.targetId) - if (current?.modelMigrationsByAppPtyId.get(ptyId) !== fence) { - return - } - if (outcome.status === 'settled') { - current.checkpointsByAppPtyId.set(ptyId, outcome.checkpoint) - } else { - current.checkpointsByAppPtyId.delete(ptyId) - current.checkpointsByAppPtyId.delete(toRelaySshPtyId(this.targetId, ptyId)) - } - current.modelMigrationsByAppPtyId.delete(ptyId) - }) - } - void migration.completion.then(() => { - closeSshPtyOutputGeneration(providerGeneration, closeReason) - }) - } - - private async finishSourceRecovery( - relayPtyId: string, - appPtyId: string, - attachResult: SshPtyAttachResult, - request: PtySourceRecoveryRequest, - pending: PendingPtyReattach, - shouldContinue: () => boolean, - activateRecoveryQuarantine: () => void - ): Promise { - const recovery = attachResult.sourceRecovery - const pendingRecovery = recovery?.status === 'pending' ? recovery : undefined - const owner = this.activePtyConsumerOwner() - if ( - !owner?.outputFlowControl || - !pendingRecovery || - request.status !== 'checkpoint' || - pendingRecovery.clientGeneration !== owner.clientGeneration || - pendingRecovery.ownerGeneration !== owner.ownerGeneration || - pendingRecovery.ptyIncarnation !== attachResult.incarnationId || - pendingRecovery.ptyIncarnation !== request.ptyIncarnation || - pendingRecovery.checkpointSourceEndSu !== request.acceptedSourceEndSu || - (pending.replacementDeliveryToken !== undefined && - pending.replacementDeliveryToken !== pendingRecovery.deliveryToken) - ) { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - const acceptedRecovery = pendingRecovery - pending.recovery = acceptedRecovery - pending.nextRecoverySourceSu = acceptedRecovery.checkpointSourceEndSu - this.retiredSourceDeliveries.activate(relayPtyId) - this.sourceIdentityByRelayPtyId.set(relayPtyId, { - deliveryToken: acceptedRecovery.deliveryToken, - clientGeneration: acceptedRecovery.clientGeneration, - ownerGeneration: acceptedRecovery.ownerGeneration, - ptyIncarnation: acceptedRecovery.ptyIncarnation, - nextSourceSu: acceptedRecovery.checkpointSourceEndSu - }) - activateRecoveryQuarantine() - for (const payload of pending.queuedData.splice(0)) { - this.routeQuarantinedReattachData(pending, payload) - } - await this.waitForRecoveryFence(pending, shouldContinue) - const exactExit = this.findExactPendingExit(pending, acceptedRecovery.ptyIncarnation) - const complete = pending.recoveryComplete ?? (exactExit ? acceptedRecovery : undefined) - if ( - !shouldContinue() || - pending.restoreRequired || - !complete || - complete.deliveryToken !== acceptedRecovery.deliveryToken || - complete.clientGeneration !== acceptedRecovery.clientGeneration || - complete.ownerGeneration !== acceptedRecovery.ownerGeneration || - complete.ptyIncarnation !== acceptedRecovery.ptyIncarnation || - complete.checkpointSourceEndSu !== acceptedRecovery.checkpointSourceEndSu || - complete.recoveryEndSu !== acceptedRecovery.recoveryEndSu || - pending.nextRecoverySourceSu !== acceptedRecovery.recoveryEndSu - ) { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - let nextLiveSourceSu = acceptedRecovery.recoveryEndSu - for (const payload of pending.liveData) { - if ( - !payload.source || - payload.source.deliveryToken !== acceptedRecovery.deliveryToken || - payload.source.clientGeneration !== acceptedRecovery.clientGeneration || - payload.source.ownerGeneration !== acceptedRecovery.ownerGeneration || - payload.source.sourceStartSu !== nextLiveSourceSu || - payload.source.sourceEndSu <= payload.source.sourceStartSu || - payload.ptyIncarnation !== acceptedRecovery.ptyIncarnation - ) { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - nextLiveSourceSu = payload.source.sourceEndSu - } - try { - for (const payload of pending.recoveryData) { - await this.acceptPtyData(payload) - } - for (const payload of pending.liveData) { - await this.acceptPtyData(payload) - } - pending.livePassthrough = true - } catch { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - const acceptedSourceEndSu = pending.liveData.reduce( - (endSu, payload) => Math.max(endSu, payload.source?.sourceEndSu ?? endSu), - acceptedRecovery.recoveryEndSu - ) - // Why: checkpoints are app-id keyed; a relay-id entry here would be shadowed - // by a staler app-id entry on the next sourceRecoveryRequest lookup. - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.set( - appPtyId, - Object.freeze({ - id: appPtyId, - providerGeneration: this.activePtyProviderGeneration!, - clientGeneration: acceptedRecovery.clientGeneration, - ownerGeneration: acceptedRecovery.ownerGeneration, - ptyIncarnation: acceptedRecovery.ptyIncarnation, - deliveryToken: acceptedRecovery.deliveryToken, - acceptedSourceEndSu - }) - ) - return true - } - - private async waitForRecoveryFence( - pending: PendingPtyReattach, - shouldContinue: () => boolean - ): Promise { - const deadline = Date.now() + SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS - while ( - shouldContinue() && - !pending.recoveryComplete && - !pending.restoreRequired && - !this.findExactPendingExit(pending, pending.recovery?.ptyIncarnation) && - Date.now() < deadline - ) { - await new Promise((resolve) => { - const timer = setTimeout( - () => { - pending.recoveryWaiters.delete(settle) - resolve() - }, - Math.max(1, deadline - Date.now()) - ) - timer.unref?.() - const settle = (): void => { - clearTimeout(timer) - resolve() - } - pending.recoveryWaiters.add(settle) - }) - } - if ( - !pending.recoveryComplete && - !pending.restoreRequired && - !this.findExactPendingExit(pending, pending.recovery?.ptyIncarnation) - ) { - pending.restoreRequired = 'recoveryFenceTimeout' - } - } - - private async abandonPtySourceRecovery( - relayPtyId: string, - appPtyId: string, - pending: PendingPtyReattach - ): Promise { - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - const recovery = pending.recovery - const { mux, providerGeneration } = pending - if (recovery && !mux.isDisposed()) { - const cancellationRequest = { - id: relayPtyId, - clientGeneration: recovery.clientGeneration, - ownerGeneration: recovery.ownerGeneration, - deliveryToken: recovery.deliveryToken - } - this.retiredSourceDeliveries.retire(providerGeneration, { - relayPtyId, - deliveryToken: recovery.deliveryToken, - clientGeneration: recovery.clientGeneration, - ownerGeneration: recovery.ownerGeneration - }) - try { - const result = (await mux.request('pty.cancelDelivery', cancellationRequest)) as Record< - string, - unknown - > - const highestPrivateSourceEndSu = pending.liveData.reduce( - (endSu, payload) => Math.max(endSu, payload.source?.sourceEndSu ?? endSu), - Math.max( - pending.nextRecoverySourceSu ?? recovery.checkpointSourceEndSu, - pending.highestRecoverySourceEndSu ?? recovery.checkpointSourceEndSu - ) - ) - if ( - result.canceled !== true || - !Number.isSafeInteger(result.sentEndSu) || - (result.sentEndSu as number) < highestPrivateSourceEndSu || - !Number.isSafeInteger(result.creditedEndSu) || - result.creditedEndSu !== recovery.checkpointSourceEndSu || - (result.creditedEndSu as number) > (result.sentEndSu as number) - ) { - throw new Error('ssh_source_cancellation_proof_invalid') - } - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - const identity = this.sourceIdentityByRelayPtyId.get(relayPtyId) - if (identity && !this.sameSourceDelivery(identity, recovery)) { - return - } - if (identity) { - const applied = applySshPtySourceRecoveryCancellationProof( - { - id: appPtyId, - code: -1, - providerGeneration, - ptyIncarnation: recovery.ptyIncarnation - }, - { - sentEndSu: result.sentEndSu as number, - creditedEndSu: result.creditedEndSu as number - } - ) - if (!applied) { - throw new Error('ssh_source_cancellation_proof_rejected') - } - } - } catch (error) { - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - console.warn( - `[ssh-relay-session] Failed to cancel replacement delivery for ${relayPtyId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - throw sourceRecoveryCancellationError(error) - } - } - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - const identity = this.sourceIdentityByRelayPtyId.get(relayPtyId) - if (!identity || !recovery || this.sameSourceDelivery(identity, recovery)) { - this.sourceIdentityByRelayPtyId.delete(relayPtyId) - } - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(appPtyId) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(relayPtyId) - this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'detached') - } - - private ownsPtyRecoveryAttempt(appPtyId: string, pending: PendingPtyReattach): boolean { - return ( - this.pendingPtyReattaches.get(appPtyId) === pending && - this.mux === pending.mux && - this.activePtyProviderGeneration === pending.providerGeneration && - !pending.mux.isDisposed() - ) - } - - private sameSourceDelivery( - left: Readonly<{ - deliveryToken: string - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - }>, - right: Readonly<{ - deliveryToken: string - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - }> - ): boolean { - return ( - left.deliveryToken === right.deliveryToken && - left.clientGeneration === right.clientGeneration && - left.ownerGeneration === right.ownerGeneration && - left.ptyIncarnation === right.ptyIncarnation - ) - } -} diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts deleted file mode 100644 index c950176dc705..000000000000 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ /dev/null @@ -1,777 +0,0 @@ -// Why: on a split-namespace host (Synology DSM) the shell path and the SFTP path -// name the same directory differently, so the deploy must keep issuing shell -// commands against the canonical path while every SFTP write is redirected — and -// only when this connection's own install marker proves the candidate is ours. - -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, - RELAY_DEPLOY_TIMEOUT_MS -} from './ssh-relay-deploy-timing' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { - abandonInstall, - finalizeInstall, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { BOTH_NATIVE_DEPS_MISSING_PROBE } from './ssh-relay-native-deps-install-fixture' -import type { SshConnection } from './ssh-connection' -import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' - -// The transfer-option slice these tests inspect; SshConnection keeps its own options type internal. -type TransferOptions = { sftpNamespace?: SftpNamespacePathMapping } - -const SHELL_HOME = '/home/u' -const SFTP_HOME = '/homes/u' -const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' -const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` -const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}` -const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/ -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' -const STAGE_RESERVED = `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0` -const STAGE_PROMOTED = `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` - -type ConnectionOptions = { - // '/homes/u' models a DSM host whose SFTP subsystem starts outside the shell home. - sftpStartPath?: string - lstatPresent?: (path: string) => boolean - hangRealpath?: boolean - // Models an SFTP session that never confirms close, so teardown stays unconfirmed. - neverCloses?: boolean - systemSsh?: boolean - // Present only on the shipping path; absent doubles exercise the deploy's own SFTP fallback. - transferMethods?: boolean -} - -type Capture = { - writePaths: string[] - uploadTargets: string[] - realpathCalls: string[] - lstatCalls: string[] - sftpEndCalls: number - uploadOptions: (TransferOptions | undefined)[] - writeOptions: (TransferOptions | undefined)[] -} - -function newCapture(): Capture { - return { - writePaths: [], - uploadTargets: [], - realpathCalls: [], - lstatCalls: [], - sftpEndCalls: 0, - uploadOptions: [], - writeOptions: [] - } -} - -// The marker this run created, read back from the shell command that made it. -function issuedMarkerNames(): string[] { - const markers: string[] = [] - for (const [, command] of vi.mocked(execCommand).mock.calls) { - const match = decodeCommand(command).match(MARKER_PATTERN) - if (match) { - markers.push(match[0]) - } - } - return markers -} - -function decodeCommand(command: string): string { - return decodeRemotePowerShellScript(command) -} - -function execCommands(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => decodeCommand(command)) -} - -function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshConnection { - const startPath = options.sftpStartPath ?? SFTP_HOME - // Default: the marker is visible only through the SFTP namespace, and only under this install's token. - const lstatPresent = - options.lstatPresent ?? - ((path: string) => - path.startsWith(`${SFTP_HOME}/`) && - issuedMarkerNames().some((marker) => path.includes(marker))) - - const makeSftp = (): unknown => { - const sftp = new EventEmitter() - return Object.assign(sftp, { - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: string) => void) => { - capture.realpathCalls.push(path) - if (options.hangRealpath) { - return - } - cb(null, startPath) - }), - lstat: vi.fn((path: string, cb: (err: Error | null) => void) => { - capture.lstatCalls.push(path) - cb(lstatPresent(path) ? null : Object.assign(new Error('No such file'), { code: 2 })) - }), - createWriteStream: vi.fn().mockImplementation((path: string) => { - capture.writePaths.push(path) - const ws = new EventEmitter() - return Object.assign(ws, { - end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) - }) - }), - end: vi.fn(() => { - capture.sftpEndCalls += 1 - if (!options.neverCloses) { - setTimeout(() => sftp.emit('close'), 0) - } - }) - }) - } - - const conn: Record = { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - sftp: vi.fn().mockImplementation(() => Promise.resolve(makeSftp())) - } - if (options.systemSsh) { - conn.usesSystemSshTransport = vi.fn().mockReturnValue(true) - } - if (options.transferMethods) { - conn.uploadDirectory = vi - .fn() - .mockImplementation((_local: string, remote: string, opts?: TransferOptions) => { - capture.uploadTargets.push(remote) - capture.uploadOptions.push(opts) - return Promise.resolve() - }) - conn.writeFile = vi - .fn() - .mockImplementation((remote: string, _contents: string, opts?: TransferOptions) => { - capture.writePaths.push(remote) - capture.writeOptions.push(opts) - return Promise.resolve() - }) - } - return conn as unknown as SshConnection -} - -function feed(responses: string[]): void { - for (const response of responses) { - vi.mocked(execCommand).mockResolvedValueOnce(response) - } -} - -// POSIX first install, healthy npm install and node-pty probe. -const POSIX_FIRST_INSTALL = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - '', // bounded stale-stage recovery - STAGE_RESERVED, - '', // chmod staged node - '', // final install namespace marker - STAGE_PROMOTED, - '', // shared native-deps cache probe (miss) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // promote into the shared native-deps cache - '', // clean stage root - 'DEAD', - 'READY' -] - -const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - '', // bounded stale-stage recovery - STAGE_RESERVED, - '', // chmod staged node - STAGE_PROMOTED, - '', // shared native-deps cache probe (miss) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // promote into the shared native-deps cache - '', // clean stage root - 'DEAD', - 'READY' -] - -// POSIX repair of an installed dir whose native deps are missing. -const POSIX_REPAIR = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, // probe before the repair lock: the marker names both deps - BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the lock - '', // install-owner marker - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' -] - -const POSIX_HEALTHY_RECONNECT = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - 'ORCA-NATIVE-DEPS-OK', - '', // per-launch namespace marker - 'DEAD', - 'READY' -] - -describe('relay install writes on a split SFTP namespace', () => { - let capture: Capture - let warnSpy: ReturnType - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => { - capture.uploadTargets.push(remote) - return Promise.resolve() - }) - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') - capture = newCapture() - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - vi.restoreAllMocks() - }) - - it('redirects every first-install artifact transfer while shell commands stay canonical', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets).toHaveLength(1) - expect(capture.uploadTargets[0]).toBe('/homes/u/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SFTP_RELAY_DIR}/package.json` - ]) - // Every shell command — mkdir, chmod, npm, launch — still names the shell path. - for (const command of execCommands()) { - expect(command).not.toContain(SFTP_RELAY_DIR) - } - expect(execCommands().some((command) => command.includes(SHELL_RELAY_DIR))).toBe(true) - }) - - it('folds the install-owner marker into the first-install mkdir', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - const markerCommands = execCommands().filter((command) => MARKER_PATTERN.test(command)) - const reservation = markerCommands.find((command) => - command.includes('__ORCA_UPLOAD_STAGE_SLOT__') - ) - const installMarker = markerCommands.find((command) => command.includes('.install-lock')) - expect(reservation).toContain('mkdir') - expect(reservation).toContain('/.orca-remote/.upload-stages') - expect(reservation).not.toContain('.install-lock') - expect(installMarker).toContain(`${SHELL_RELAY_DIR}/.install-lock`) - }) - - it('probes one shared marker for every first-install artifact transfer', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - const [stageMarker, installMarker] = issuedMarkerNames() - expect(stageMarker).toMatch(MARKER_PATTERN) - expect(installMarker).toMatch(MARKER_PATTERN) - expect(capture.lstatCalls).toHaveLength(6) - for (const path of capture.lstatCalls.slice(0, 4)) { - expect(path).toContain('.upload-stages') - expect(path).toContain(stageMarker) - } - expect(capture.lstatCalls.slice(4)).toEqual([ - `${SHELL_RELAY_DIR}/.install-lock/${installMarker}`, - `${SFTP_RELAY_DIR}/.install-lock/${installMarker}` - ]) - }) - - it('refuses a same-version candidate dir that carries another install marker', async () => { - const foreign = `.sftp-namespace-${'f'.repeat(32)}` - const conn = makeConnection(capture, { - lstatPresent: (path) => path.startsWith(`${SFTP_HOME}/`) && path.includes(foreign) - }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SHELL_RELAY_DIR}/package.json` - ]) - }) - - it('keeps the shell path and skips probing when both namespaces agree', async () => { - const conn = makeConnection(capture, { sftpStartPath: SHELL_HOME }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.lstatCalls).toEqual([]) - }) - - it('resolves against a start directory outside any home', async () => { - const conn = makeConnection(capture, { - sftpStartPath: '/volume1/shared', - lstatPresent: (path) => path.startsWith('/volume1/shared/') - }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets[0]).toContain( - '/volume1/shared/.orca-remote/.upload-stages/slot-0/payload' - ) - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - }) - - it('passes the same mapping to a connection that owns its own transfer methods', async () => { - const conn = makeConnection(capture, { transferMethods: true }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - // The shipping path hands over shell paths plus a mapping; resolution happens on the write session. - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SHELL_RELAY_DIR}/package.json` - ]) - const [stageMarker, installMarker] = issuedMarkerNames() - const mappings = [...capture.uploadOptions, ...capture.writeOptions].map( - (options) => options?.sftpNamespace - ) - expect(mappings).toHaveLength(3) - expect(mappings[0]?.shellProbePath).toContain('/.orca-remote/.upload-stages/slot-0/') - expect(mappings[0]?.shellProbePath).toContain(stageMarker) - expect(mappings[1]?.shellProbePath).toBe(mappings[0]?.shellProbePath) - expect(mappings[2]?.shellProbePath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${installMarker}`) - expect(mappings[0]?.homeRelativePath).toBe('.orca-remote/.upload-stages/slot-0/payload') - expect(mappings[1]?.homeRelativePath).toBe(`${mappings[0]?.homeRelativePath}/.version`) - expect(mappings[2]?.homeRelativePath).toBe(`${RELAY_SUFFIX}/package.json`) - expect(capture.realpathCalls).toEqual([]) - }) - - it('leaves system-SSH connections unmapped and unprobed', async () => { - // transferMethods models the shipping SshConnection path (uploadDirectory/writeFile → system SSH helpers). - const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) - feed(POSIX_SYSTEM_SSH_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(true) - expect(capture.realpathCalls).toEqual([]) - expect(capture.lstatCalls).toEqual([]) - expect(conn.sftp).not.toHaveBeenCalled() - // System SSH never retargets: shell absolute paths, no mapping, no SFTP session. - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SHELL_RELAY_DIR}/package.json` - ]) - const transferOptions = [...capture.uploadOptions, ...capture.writeOptions] - expect(transferOptions).toHaveLength(3) - for (const options of transferOptions) { - expect(options?.sftpNamespace).toBeUndefined() - } - }) - - it('leaves Windows hosts unmapped and unprobed', async () => { - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('win32-x64') - const conn = makeConnection(capture) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const decoded = decodeCommand(command) - if (decoded.includes('__ORCA_REMOTE_PLATFORM__')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Windows AMD64') - } - if (decoded.includes('[Environment]::GetFolderPath')) { - return Promise.resolve('C:\\Users\\u') - } - if (decoded.includes('__ORCA_UPLOAD_STAGE_SLOT__')) { - return Promise.resolve(STAGE_RESERVED) - } - if (decoded.includes('__ORCA_UPLOAD_STAGE_PROMOTION__')) { - return Promise.resolve(STAGE_PROMOTED) - } - if (decoded.includes('npm install')) { - return Promise.reject(new Error('npm install failed')) - } - return Promise.resolve('') - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('npm install failed') - - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(true) - expect(capture.realpathCalls).toEqual([]) - expect(capture.writePaths[0]).toBe( - 'C:/Users/u/.orca-remote/.upload-stages/slot-0/payload/.version' - ) - expect(capture.writePaths[1]).toBe('C:/Users/u/.orca-remote/relay-0.1.0+testhash/package.json') - }) - - it('releases the first-install lock when a redirected upload fails', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - vi.mocked(uploadDirectory).mockRejectedValueOnce(new Error('sftp write failed')) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('sftp write failed') - - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('ends the SFTP session once when a deploy abort strands namespace discovery', async () => { - vi.useFakeTimers() - try { - const conn = makeConnection(capture, { hangRealpath: true }) - feed(POSIX_FIRST_INSTALL) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - const result = await deploy - - expect((result as Error).message).toContain('Relay deployment timed out') - await vi.advanceTimersByTimeAsync(5_000) - expect(capture.sftpEndCalls).toBe(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('retains the first-install lock when the aborted SFTP session never closes', async () => { - vi.useFakeTimers() - try { - const conn = makeConnection(capture, { hangRealpath: true, neverCloses: true }) - feed(POSIX_FIRST_INSTALL) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - await deploy - - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) -}) - -describe('relay repair writes on a split SFTP namespace', () => { - let capture: Capture - let warnSpy: ReturnType - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') - capture = newCapture() - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - vi.restoreAllMocks() - }) - - it('leaves credential publication to the relay on a healthy reconnect', async () => { - const conn = makeConnection(capture, { transferMethods: true }) - feed(POSIX_HEALTHY_RECONNECT) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it('writes no credential through fallback SFTP on a healthy reconnect', async () => { - const conn = makeConnection(capture) - feed(POSIX_HEALTHY_RECONNECT) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it.each(['busy', 'error'] as const)( - 'launches under a %s repair lock without writing a credential', - async (lockResult) => { - const conn = makeConnection(capture) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue(lockResult) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - 'ORCA-NATIVE-DEPS-OK', - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - } - ) - - it('keeps a healthy system-SSH reconnect on its secure shell-path writer', async () => { - const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - 'ORCA-NATIVE-DEPS-OK', - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(conn.sftp).not.toHaveBeenCalled() - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - expect(capture.writePaths).toEqual([]) - }) - - it('launches without a client-side credential when a healthy marker is unavailable', async () => { - const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'ORCA-NATIVE-DEPS-OK']) - vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only marker')) - feed(['DEAD', 'READY']) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it('stamps the marker only after the locked recheck, then redirects package.json', async () => { - const conn = makeConnection(capture) - let execCountAtLock = -1 - vi.mocked(tryAcquireRelayRepairLock).mockImplementation(() => { - execCountAtLock = vi.mocked(execCommand).mock.calls.length - return Promise.resolve('acquired') - }) - feed(POSIX_REPAIR) - - await deployAndLaunchRelay(conn) - - const commands = execCommands() - const markerIndex = commands.findIndex((command) => MARKER_PATTERN.test(command)) - expect(markerIndex).toBeGreaterThan(execCountAtLock) - // The re-probe under the lock is the last exec before the marker. - expect(commands[markerIndex - 1]).toContain('loadNativeModule') - expect(capture.writePaths).toEqual([`${SFTP_RELAY_DIR}/package.json`]) - }) - - it('does not stamp a marker when repairing over system SSH', async () => { - const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, - BOTH_NATIVE_DEPS_MISSING_PROBE, - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) - expect(conn.sftp).not.toHaveBeenCalled() - expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) - expect(capture.writeOptions).toEqual([expect.objectContaining({ sftpNamespace: undefined })]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it('degrades to shell paths when marker creation fails outright', async () => { - const conn = makeConnection(capture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, - BOTH_NATIVE_DEPS_MISSING_PROBE - ]) - vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system')) - feed([ - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - expect(capture.realpathCalls).toEqual([]) - expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( - expect.stringContaining('SFTP namespace marker unavailable') - ) - }) - - it('keeps the repair lock when marker creation has unconfirmed termination', async () => { - const conn = makeConnection(capture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, - BOTH_NATIVE_DEPS_MISSING_PROBE - ]) - vi.mocked(execCommand).mockRejectedValueOnce( - Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false }) - ) - feed(['DEAD', 'READY']) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( - expect.stringContaining('launching degraded') - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts b/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts deleted file mode 100644 index fe6907acd8d1..000000000000 --- a/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { execFileSync, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { listRelayBaseDirsCommand } from './ssh-remote-commands' -import { gcOldRelayVersions } from './ssh-relay-versioned-install' -import type { SshTarget } from '../../shared/ssh-types' - -const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_UPLOAD_CANCEL === '1' -const REMOTE_REPO = '/tmp/orca-pr-10207-real-repo' - -type TargetFixture = { - containerName: string - identityFile: string - port: number - tempDir: string -} - -type RemoteInventory = { - installLock: boolean - payloadFiles: number - uploadStages: string[] -} - -function run(command: string, args: string[], timeout = 30_000): string { - return execFileSync(command, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout - }).trim() -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - -function dockerExec(fixture: TargetFixture, command: string): string { - return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000) -} - -function startTarget(): TargetFixture { - const image = process.env.ORCA_REVIEW_SSH_IMAGE - if (!image) { - throw new Error('ORCA_REVIEW_SSH_IMAGE is required') - } - const tempDir = mkdtempSync(join(tmpdir(), 'orca-pr10207-ssh-')) - const identityFile = join(tempDir, 'id_ed25519') - run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) - const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() - const containerName = `orca-pr10207-${randomUUID().slice(0, 12)}` - run( - 'docker', - [ - 'run', - '-d', - '--name', - containerName, - '-p', - '0.0.0.0::22', - '-e', - `AUTHORIZED_KEY=${publicKey}`, - image, - 'bash', - '-lc', - 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' - ], - 120_000 - ) - const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) - const fixture = { containerName, identityFile, port, tempDir } - dockerExec( - fixture, - [ - `mkdir -p ${shellQuote(REMOTE_REPO)}`, - `cd ${shellQuote(REMOTE_REPO)}`, - 'git init', - 'git config user.email review@test.local', - 'git config user.name "PR 10207 Review"', - "printf '%s\\n' real-docker-ssh-repository > README.md", - 'git add README.md', - 'git commit -m initial' - ].join(' && ') - ) - return fixture -} - -function stopTarget(fixture: TargetFixture | null): void { - if (!fixture) { - return - } - spawnSync('docker', ['stop', fixture.containerName], { stdio: 'ignore', timeout: 20_000 }) - spawnSync('docker', ['rm', fixture.containerName], { stdio: 'ignore', timeout: 20_000 }) - rmSync(fixture.tempDir, { recursive: true, force: true }) -} - -function createConnection(fixture: TargetFixture): SshConnection { - const host = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '' - if (!host || host === 'localhost' || host === '::1' || host.startsWith('127.')) { - throw new Error(`Review SSH target must be non-loopback, received ${JSON.stringify(host)}`) - } - const target: SshTarget = { - id: `pr-10207-${randomUUID()}`, - label: 'PR 10207 Docker SSH target', - source: 'manual', - host, - port: fixture.port, - username: 'root', - identityFile: fixture.identityFile, - identitiesOnly: true - } - return new SshConnection(target, { onStateChange: vi.fn() }) -} - -function readInventory(fixture: TargetFixture, remoteRelayDir: string): RemoteInventory { - const stagePool = '/root/.orca-remote/.upload-stages' - const raw = dockerExec( - fixture, - [ - `lock=0; test -d ${shellQuote(`${remoteRelayDir}/.install-lock`)} && lock=1`, - `files=0; test -d ${shellQuote(remoteRelayDir)} && files=$(find ${shellQuote(remoteRelayDir)} -type f ! -path '*/.install-lock/*' | wc -l | tr -d ' ')`, - `printf 'LOCK=%s\\nFILES=%s\\n' "$lock" "$files"`, - `find ${shellQuote(stagePool)} -mindepth 1 -maxdepth 1 \\( -name 'slot-*' -o -name 'claim-*' -o -name 'delete-*' \\) -print 2>/dev/null | sort || true` - ].join('; ') - ) - const lines = raw.split(/\r?\n/) - return { - installLock: lines[0] === 'LOCK=1', - payloadFiles: Number(lines[1]?.slice('FILES='.length) ?? 0), - uploadStages: lines.slice(2).filter(Boolean) - } -} - -describe.skipIf(!RUN_REVIEW_ORACLE)('SSH relay upload cancellation recovery', () => { - let fixture: TargetFixture | null = null - - beforeAll(() => { - fixture = startTarget() - }) - - afterAll(() => { - stopTarget(fixture) - }) - - it('recovers a post-promotion install lock from a previous execution-host boot', async () => { - const activeFixture = fixture as TargetFixture - const remoteRelayDir = '/root/.orca-remote/relay-reboot-lock-oracle' - const previousBootId = 'linux:00000000-0000-0000-0000-000000000000:0' - dockerExec( - activeFixture, - [ - `rm -rf ${shellQuote(remoteRelayDir)}`, - `mkdir -p ${shellQuote(`${remoteRelayDir}/.install-lock`)} ${shellQuote(`${remoteRelayDir}/node_modules`)}`, - `printf '%s\\n' promoted > ${shellQuote(`${remoteRelayDir}/relay.js`)}`, - `printf '%s\\n' ${shellQuote(previousBootId)} > ${shellQuote(`${remoteRelayDir}/.install-lock/.boot-id`)}` - ].join(' && ') - ) - const connection = createConnection(activeFixture) - await connection.connect() - try { - const startedAt = Date.now() - await acquireInstallLock(connection, remoteRelayDir, getRemoteHostPlatform('linux-arm64')) - const elapsedMs = Date.now() - startedAt - const state = dockerExec( - activeFixture, - [ - `cat ${shellQuote(`${remoteRelayDir}/.install-lock/.boot-id`)}`, - `cat ${shellQuote(`${remoteRelayDir}/relay.js`)}`, - `find ${shellQuote(remoteRelayDir)} -maxdepth 1 -name '.install-lock.tombstone.*' -print | wc -l | tr -d ' '` - ].join('; ') - ).split(/\r?\n/u) - - expect(elapsedMs).toBeLessThan(10_000) - expect(state[0]).toMatch(/^linux:[0-9a-f-]+:[0-9]+$/u) - expect(state[0]).not.toBe(previousBootId) - expect(state[1]).toBe('promoted') - expect(state[2]).toBe('0') - } finally { - await connection.disconnect() - dockerExec(activeFixture, `rm -rf ${shellQuote(remoteRelayDir)}`) - } - }, 60_000) - - it('aborts a live SFTP upload after remote bytes arrive without creating the shared lock', async () => { - const activeFixture = fixture as TargetFixture - const localRelayDir = join(process.cwd(), 'out', 'relay', 'linux-arm64') - const relayVersion = readFileSync(join(localRelayDir, '.version'), 'utf8').trim() - const relayJsSize = statSync(join(localRelayDir, 'relay.js')).size - const remoteRelayDir = `/root/.orca-remote/relay-${relayVersion}` - const connection = createConnection(activeFixture) - await connection.connect() - const sentinelPid = dockerExec(activeFixture, 'sleep 300 /dev/null 2>&1 & echo $!') - let releaseFirstWrite: () => void = () => {} - const firstWrite = new Promise((resolve) => { - releaseFirstWrite = resolve - }) - let acknowledgedBytes = 0 - const openSftp = connection.sftp.bind(connection) - connection.sftp = vi.fn(async (signal) => { - const sftp = await openSftp(signal) - const createWriteStream = sftp.createWriteStream.bind(sftp) - sftp.createWriteStream = ((...args: Parameters) => { - const [remotePath] = args - const stream = createWriteStream(...args) - if (!remotePath.endsWith('/payload/relay.js')) { - return stream - } - const writable = stream as typeof stream & { - _write: ( - chunk: Buffer, - encoding: BufferEncoding, - callback: (error?: Error | null) => void - ) => void - } - const write = writable._write.bind(writable) - writable._write = (chunk, encoding, callback): void => { - write(chunk, encoding, (error) => { - if (error) { - callback(error) - return - } - acknowledgedBytes += chunk.length - releaseFirstWrite() - }) - } - return stream - }) as typeof sftp.createWriteStream - return sftp - }) - - const deployment = deployAndLaunchRelay(connection, undefined, 60) - let barrierTimeout: ReturnType - try { - await Promise.race([ - firstWrite, - new Promise((_resolve, reject) => { - barrierTimeout = setTimeout( - () => reject(new Error('Timed out waiting for first remote SFTP chunk')), - 30_000 - ) - }) - ]) - } finally { - clearTimeout(barrierTimeout!) - } - const partialRemoteBytes = Number( - dockerExec( - activeFixture, - "find /root/.orca-remote/.upload-stages -type f -path '*/slot-*/payload/relay.js' -printf '%s\\n'" - ) - ) - const operationController = ( - connection as unknown as { systemOperationAbortController: AbortController } - ).systemOperationAbortController - operationController.abort() - - await expect(deployment).rejects.toMatchObject({ name: 'AbortError' }) - const inventory = readInventory(activeFixture, remoteRelayDir) - const sentinelCommand = dockerExec( - activeFixture, - `tr '\\0' ' ' < /proc/${shellQuote(sentinelPid)}/cmdline` - ) - await connection.disconnect() - - console.log( - `[pr-10207-live-sftp-abort] ${JSON.stringify({ acknowledgedBytes, partialRemoteBytes, relayJsSize, inventory, sentinelPid, sentinelCommand })}` - ) - expect(acknowledgedBytes).toBeGreaterThan(0) - expect(partialRemoteBytes).toBe(acknowledgedBytes) - expect(partialRemoteBytes).toBeLessThan(relayJsSize) - expect(inventory.installLock).toBe(false) - expect(sentinelCommand).toContain('sleep 300') - }, 180_000) - - it('recovers cancellation with bounded safe reclamation and bounded real version GC', async () => { - const activeFixture = fixture as TargetFixture - const relayVersion = readFileSync( - join(process.cwd(), 'out', 'relay', 'linux-arm64', '.version'), - 'utf8' - ).trim() - const remoteRelayDir = `/root/.orca-remote/relay-${relayVersion}` - const firstConnection = createConnection(activeFixture) - const progress: string[] = [] - await firstConnection.connect() - const unconfirmedCancellation = Object.assign(new Error('injected upload cancellation'), { - sshChannelCloseConfirmed: false - }) - firstConnection.uploadDirectory = vi.fn().mockRejectedValue(unconfirmedCancellation) - - await expect( - deployAndLaunchRelay(firstConnection, (status) => progress.push(status), 60) - ).rejects.toBe(unconfirmedCancellation) - await firstConnection.disconnect() - const firstInventory = readInventory(activeFixture, remoteRelayDir) - const expected = process.env.ORCA_REVIEW_EXPECT_RECOVERY === '1' ? 'recovered' : 'blocked' - if (expected === 'recovered') { - const secondAbandonedConnection = createConnection(activeFixture) - await secondAbandonedConnection.connect() - secondAbandonedConnection.uploadDirectory = vi.fn().mockRejectedValue(unconfirmedCancellation) - await expect(deployAndLaunchRelay(secondAbandonedConnection, undefined, 60)).rejects.toBe( - unconfirmedCancellation - ) - await secondAbandonedConnection.disconnect() - } - - const retryConnection = createConnection(activeFixture) - await retryConnection.connect() - let retryResult: 'blocked' | 'recovered' - let finalInventory: RemoteInventory | undefined - let scaleEvidence: - | { gcDurationMs: number; listingBytes: number; scaleEntries: number } - | undefined - if (firstInventory.installLock) { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), 1_500) - await expect( - acquireInstallLock(retryConnection, remoteRelayDir, getRemoteHostPlatform('linux-arm64'), { - signal: controller.signal - }) - ).rejects.toMatchObject({ name: 'AbortError' }) - clearTimeout(timer) - retryResult = 'blocked' - } else { - const deployed = await deployAndLaunchRelay(retryConnection, undefined, 60) - const mux = new SshChannelMultiplexer(deployed.transport) - await expect(mux.request('session.resolveHome', { path: '~' })).resolves.toEqual({ - resolvedPath: '/root' - }) - mux.dispose() - retryResult = 'recovered' - } - const repoHead = await execCommand( - retryConnection, - `cd ${shellQuote(REMOTE_REPO)} && git rev-parse --verify HEAD` - ) - await retryConnection.disconnect() - - if (expected === 'recovered') { - const replacedStage = firstInventory.uploadStages[0]! - const originalStage = `${replacedStage}.original` - const foreignTarget = '/root/orca-pr10207-foreign-stage-target' - const stagePool = '/root/.orca-remote/.upload-stages' - const symlinkStage = `${stagePool}/slot-2` - const ownerMarker = '.orca-upload-owner' - const identityMarker = '.orca-upload-identity' - dockerExec( - activeFixture, - [ - `mv ${shellQuote(replacedStage)} ${shellQuote(originalStage)}`, - `mkdir -p ${shellQuote(`${replacedStage}/payload`)} ${shellQuote(foreignTarget)}`, - `cp ${shellQuote(`${originalStage}/${ownerMarker}`)} ${shellQuote(`${replacedStage}/${ownerMarker}`)}`, - `cp ${shellQuote(`${originalStage}/${identityMarker}`)} ${shellQuote(`${replacedStage}/${identityMarker}`)}`, - `touch -d '3 hours ago' ${shellQuote(`${replacedStage}/${ownerMarker}`)} ${shellQuote(`${originalStage}/${ownerMarker}`)}`, - `printf foreign > ${shellQuote(`${replacedStage}/payload/foreign`)}`, - `printf alive > ${shellQuote(`${foreignTarget}/sentinel`)}`, - `ln -s ${shellQuote(foreignTarget)} ${shellQuote(symlinkStage)}`, - `i=0; while [ "$i" -lt 15197 ]; do mkdir ${shellQuote(`/root/.orca-remote/relay-${relayVersion}.upload-scale-`)}"$i"; i=$((i + 1)); done` - ].join(' && ') - ) - const adversarialInventory = readInventory(activeFixture, remoteRelayDir) - - const reclaimableStage = `${stagePool}/slot-1` - dockerExec( - activeFixture, - `touch -d '3 hours ago' ${shellQuote(`${reclaimableStage}/${ownerMarker}`)}` - ) - - const cleanupConnection = createConnection(activeFixture) - await cleanupConnection.connect() - const cleanedDeployment = await deployAndLaunchRelay(cleanupConnection, undefined, 60) - const cleanupMux = new SshChannelMultiplexer(cleanedDeployment.transport) - await expect(cleanupMux.request('session.resolveHome', { path: '~' })).resolves.toEqual({ - resolvedPath: '/root' - }) - const reclaimDeadline = Date.now() + 10_000 - while ( - dockerExec( - activeFixture, - `test -e ${shellQuote(reclaimableStage)} && echo PRESENT || true` - ) && - Date.now() < reclaimDeadline - ) { - await new Promise((resolve) => setTimeout(resolve, 50)) - } - expect( - dockerExec(activeFixture, `test ! -e ${shellQuote(reclaimableStage)} && echo RECLAIMED`) - ).toBe('RECLAIMED') - - const listing = await execCommand( - cleanupConnection, - listRelayBaseDirsCommand(getRemoteHostPlatform('linux-arm64'), '/root/.orca-remote') - ) - const gcStartedAt = Date.now() - await gcOldRelayVersions( - cleanupConnection, - '/root', - remoteRelayDir, - getRemoteHostPlatform('linux-arm64') - ) - const gcDurationMs = Date.now() - gcStartedAt - finalInventory = readInventory(activeFixture, remoteRelayDir) - expect( - dockerExec( - activeFixture, - `test -L ${shellQuote(symlinkStage)} && test -d ${shellQuote(foreignTarget)} && test "$(cat ${shellQuote(`${foreignTarget}/sentinel`)})" = alive && echo PRESERVED` - ).trim() - ).toBe('PRESERVED') - expect( - dockerExec( - activeFixture, - `test -d ${shellQuote(replacedStage)} && test -d ${shellQuote(originalStage)} && echo PRESERVED` - ).trim() - ).toBe('PRESERVED') - expect(finalInventory.uploadStages.sort()).toEqual( - adversarialInventory.uploadStages.filter((stage) => stage !== reclaimableStage).sort() - ) - const listingBytes = Buffer.byteLength(listing) - expect(listingBytes).toBeLessThan(1_024) - expect(gcDurationMs).toBeLessThan(10_000) - const scaleEntries = Number( - dockerExec( - activeFixture, - `find /root/.orca-remote -mindepth 1 -maxdepth 1 -type d -name ${shellQuote(`relay-${relayVersion}.upload-scale-*`)} | wc -l` - ) - ) - expect(scaleEntries).toBe(15_197) - scaleEvidence = { gcDurationMs, listingBytes, scaleEntries } - cleanupMux.dispose() - await cleanupConnection.disconnect() - } - console.log( - `[pr-10207-oracle] ${JSON.stringify({ progress, firstInventory, retryResult, finalInventory, scaleEvidence, repoHead: repoHead.trim() })}` - ) - expect(progress).toContain('Uploading relay...') - expect(repoHead.trim()).toMatch(/^[0-9a-f]{40}$/) - expect(retryResult).toBe(expected) - if (expected === 'recovered') { - expect(firstInventory.installLock).toBe(false) - expect(firstInventory.uploadStages.length).toBeGreaterThan(0) - expect(finalInventory?.installLock).toBe(false) - expect(finalInventory!.uploadStages).toEqual([ - '/root/.orca-remote/.upload-stages/slot-0', - '/root/.orca-remote/.upload-stages/slot-0.original', - '/root/.orca-remote/.upload-stages/slot-2' - ]) - } else { - expect(firstInventory.installLock).toBe(true) - expect(firstInventory.payloadFiles).toBe(0) - } - }, 180_000) -}) diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts deleted file mode 100644 index 0b5d0a741493..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - renameSync, - rmSync, - symlinkSync, - utimesSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - promoteOwnedRelayUploadStageCommand, - recoverOneStaleRelayUploadStageCommand, - relayUploadStagePromotionConfirmed, - reserveRelayUploadStageCommand, - RELAY_UPLOAD_STAGE_SLOT_COUNT -} from './ssh-relay-upload-stage-commands' - -const posix = getRemoteHostPlatform('linux-x64') -const windows = getRemoteHostPlatform('win32-x64') -const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' -const roots: string[] = [] -const configuredPowerShell = process.env.ORCA_POWERSHELL_EXECUTABLE -const powerShellExecutable = [ - configuredPowerShell, - ...(process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh']) -].find( - (candidate) => - candidate && - spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { - stdio: 'ignore' - }).status === 0 -) - -function decodePowerShellCommand(command: string): string { - const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/u)?.[1] ?? '' - return Buffer.from(encoded, 'base64').toString('utf16le') -} - -function createPool(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-upload-stage-pool-')) - roots.push(root) - const pool = join(root, 'pool') - mkdirSync(pool) - return pool -} - -function runCommand(host: RemoteHostPlatform, command: string, prefix = '') { - if (host.commandDialect === 'powershell') { - if (!powerShellExecutable) { - throw new Error('PowerShell is unavailable') - } - return spawnSync( - powerShellExecutable, - [ - '-NoProfile', - '-NonInteractive', - '-Command', - `${prefix}\n${decodePowerShellCommand(command)}` - ], - { encoding: 'utf8' } - ) - } - return spawnSync('/bin/sh', ['-c', `${prefix}\n${command}`], { encoding: 'utf8' }) -} - -function createStage(host: RemoteHostPlatform, pool: string, index: number): string { - const reservation = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) - expect(reservation.status, reservation.stderr).toBe(0) - return populateReservedStage(pool, index) -} - -function populateReservedStage( - pool: string, - index: number, - replacementOwner?: string, - stale = false, - state: 'slot' | 'claim' | 'delete' = 'slot' -): string { - const slot = join(pool, `slot-${index}`) - const stage = join(pool, `${state}-${index}`) - if (stage !== slot) { - expect(existsSync(stage)).toBe(false) - renameSync(slot, stage) - } - const marker = join(stage, '.orca-upload-owner') - if (replacementOwner !== undefined) { - writeFileSync(marker, replacementOwner) - } - writeFileSync(join(stage, 'payload', 'relay.js'), `relay-${index}`) - if (stale) { - const old = new Date(Date.now() - 60 * 60_000) - utimesSync(marker, old, old) - } - return stage -} - -function createStages(host: RemoteHostPlatform, pool: string, count: number): void { - if (host.commandDialect !== 'powershell' || count < 2) { - for (let index = 0; index < count; index += 1) { - createStage(host, pool, index) - } - return - } - const command = reserveRelayUploadStageCommand(host, pool, owner) - // Only fixture setup shares a process; each unmodified script gets a fresh local scope. - const batch = powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - `$fixtureScript = ${powerShellLiteral(decodePowerShellCommand(command))}`, - '$reservation = [PowerShell]::Create()', - 'try {', - `foreach ($fixtureIndex in 1..${count}) {`, - '$reservation.Commands.Clear()', - '$reservation.Streams.Error.Clear()', - '$null = $reservation.AddScript($fixtureScript, $true)', - '$reservation.Invoke()', - 'if ($reservation.InvocationStateInfo.State -ne "Completed") { throw $reservation.InvocationStateInfo.Reason }', - '}', - '} finally { $reservation.Dispose() }' - ].join('\n') - ) - const result = runCommand(host, batch) - expect(result.status, result.stderr).toBe(0) - const reservations = result.stdout.trim().split(/\r?\n/u) - expect(reservations).toHaveLength(count) - for (const [index, output] of reservations.entries()) { - expect(parseReservedRelayUploadStage(host, pool, owner, output).slotName).toBe(`slot-${index}`) - populateReservedStage(pool, index) - } -} - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -// Allow for real interpreter startup and file-identity probes under full-suite contention. -const SPAWNED_INTERPRETER_TIMEOUT_MS = 240_000 - -describe.each([ - ['POSIX', posix] as const, - ...((powerShellExecutable ? [['PowerShell', windows] as const] : []) as (readonly [ - string, - RemoteHostPlatform - ])[]) -])( - '%s relay upload stage commands', - { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, - (_label, host) => { - it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { - const pool = createPool() - createStages(host, pool, Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT)) - if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) { - mkdirSync(join(pool, 'foreign-extra')) - } - - const result = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) - if (count < RELAY_UPLOAD_STAGE_SLOT_COUNT) { - expect(result.status, result.stderr).toBe(0) - expect(parseReservedRelayUploadStage(host, pool, owner, result.stdout).slotName).toBe( - `slot-${count}` - ) - } else { - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('staging quota is full') - } - }) - - it('promotes only the post-claim owned payload and removes its fixed stage', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(host, pool, 0) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `noise\n__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0\n` - ) - - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(true) - expect(readFileSync(join(destination, 'relay.js'), 'utf8')).toBe('relay-0') - expect(existsSync(join(pool, 'slot-0'))).toBe(false) - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-0'))).toBe(false) - }) - - // Why: symlink creation in the fixture needs privileges Windows CI doesn't grant. - it.skipIf(process.platform === 'win32')( - 'rejects a payload reparse point without copying or deleting it', - () => { - const pool = createPool() - const destination = join(pool, 'destination') - const foreign = join(pool, 'foreign.js') - mkdirSync(destination) - const stagePath = createStage(host, pool, 0) - rmSync(join(stagePath, 'payload', 'relay.js')) - writeFileSync(foreign, 'foreign') - symlinkSync(foreign, join(stagePath, 'payload', 'relay.js')) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - expect(lstatSync(join(pool, 'slot-0', 'payload', 'relay.js')).isSymbolicLink()).toBe(true) - expect(readFileSync(foreign, 'utf8')).toBe('foreign') - } - ) - - it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { - const pool = createPool() - createStages(host, pool, 3) - populateReservedStage(pool, 1, '.foreign-owner', true) - populateReservedStage(pool, 2, undefined, true) - - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) - - expect(result.status, result.stderr).toBe(0) - expect(existsSync(join(pool, 'slot-0'))).toBe(true) - expect(existsSync(join(pool, 'slot-1'))).toBe(true) - expect(existsSync(join(pool, 'slot-2'))).toBe(false) - }) - - it('drains fixed stale claim and delete states across repeated deployments', () => { - const pool = createPool() - createStages(host, pool, 2) - populateReservedStage(pool, 0, undefined, true, 'claim') - populateReservedStage(pool, 1, undefined, true, 'delete') - - for (let attempt = 0; attempt < 2; attempt += 1) { - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) - expect(result.status, result.stderr).toBe(0) - } - - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-1'))).toBe(false) - }) - } -) - -describe('POSIX ownership race fencing', () => { - it('restores a replacement directory and preserves the original moved aside before claim', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(posix, pool, 0) - const stage = parseReservedRelayUploadStage( - posix, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - 'raced=0', - 'mv() {', - 'if [ "$raced" -eq 0 ]; then', - 'raced=1; command mv "$1" "$1.original"; mkdir "$1"; mkdir "$1/payload"; cp "$1.original/.orca-upload-owner" "$1/.orca-upload-owner"; cp "$1.original/.orca-upload-identity" "$1/.orca-upload-identity"; touch -t 202001010000 "$1/.orca-upload-owner"; printf foreign > "$1/foreign";', - 'fi;', - 'command mv "$@";', - '}' - ].join('\n') - - const result = runCommand( - posix, - promoteOwnedRelayUploadStageCommand(posix, stage, owner, destination), - prefix - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - }) - - it('restores a replacement symlink without touching its target', () => { - const pool = createPool() - const destination = join(pool, 'destination') - const foreign = join(pool, 'foreign') - mkdirSync(destination) - mkdirSync(foreign) - writeFileSync(join(foreign, 'sentinel'), 'alive') - createStage(posix, pool, 0) - const stage = parseReservedRelayUploadStage( - posix, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - 'raced=0', - 'mv() {', - 'if [ "$raced" -eq 0 ]; then raced=1; command mv "$1" "$1.original"; ln -s ' + - `'${foreign}' "$1"; fi;`, - 'command mv "$@";', - '}' - ].join('\n') - - const result = runCommand( - posix, - cleanupOwnedRelayUploadStageCommand(posix, stage, owner), - prefix - ) - - expect(result.status, result.stderr).toBe(0) - expect(lstatSync(join(pool, 'slot-0')).isSymbolicLink()).toBe(true) - expect(readFileSync(join(foreign, 'sentinel'), 'utf8')).toBe('alive') - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - }) -}) - -describe.runIf(powerShellExecutable)( - 'PowerShell ownership race fencing', - { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, - () => { - it('restores an old same-owner replacement whose persisted file ID does not match', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(windows, pool, 0) - const stage = parseReservedRelayUploadStage( - windows, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - '$script:raced = $false', - 'function Move-Item {', - 'param($LiteralPath, $Destination, $ErrorAction)', - 'if (-not $script:raced) {', - '$script:raced = $true', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination ($LiteralPath + ".original") -ErrorAction Stop', - '$null = New-Item -ItemType Directory -Path $LiteralPath', - '$null = New-Item -ItemType Directory -Path (Join-Path $LiteralPath "payload")', - '$newPath = (Get-Item -LiteralPath $LiteralPath).FullName', - '$originalPath = (Get-Item -LiteralPath ($LiteralPath + ".original")).FullName', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-owner"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-owner")))', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-identity"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-identity")))', - '(Get-Item -LiteralPath (Join-Path $newPath ".orca-upload-owner") -Force).LastWriteTimeUtc = [DateTime]::UtcNow.AddHours(-2)', - '[System.IO.File]::WriteAllText((Join-Path $newPath "foreign"), "foreign")', - '}', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination $Destination -ErrorAction $ErrorAction', - '}' - ].join('\n') - - const result = runCommand( - windows, - promoteOwnedRelayUploadStageCommand(windows, stage, owner, destination), - prefix - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - }) - } -) diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.ts b/src/main/ssh/ssh-relay-upload-stage-commands.ts deleted file mode 100644 index e363a574a574..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-commands.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { shellEscape } from './ssh-connection-utils' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - RELAY_UPLOAD_IDENTITY_FILE_NAME as IDENTITY_FILE_NAME, - RELAY_UPLOAD_OWNER_FILE_NAME as OWNER_FILE_NAME, - RELAY_UPLOAD_PROMOTION_RESULT_PREFIX as PROMOTION_RESULT_PREFIX, - RELAY_UPLOAD_SLOT_RESULT_PREFIX as SLOT_RESULT_PREFIX, - RELAY_UPLOAD_STAGE_POOL_NAME, - RELAY_UPLOAD_STAGE_SLOT_COUNT, - RELAY_UPLOAD_STAGE_STALE_SECONDS, - type RelayUploadStageSlot -} from './ssh-relay-upload-stage-contract' -import { - cleanupWindowsRelayUploadStageCommand, - promoteWindowsRelayUploadStageCommand, - recoverWindowsRelayUploadStageCommand, - reserveWindowsRelayUploadStageCommand, - type WindowsUploadStageIdentity -} from './ssh-relay-upload-stage-windows-commands' - -export type { WindowsUploadStageIdentity } from './ssh-relay-upload-stage-windows-commands' -export { - RELAY_UPLOAD_STAGE_POOL_NAME, - RELAY_UPLOAD_STAGE_SLOT_COUNT, - RELAY_UPLOAD_STAGE_STALE_SECONDS, - type RelayUploadStageSlot -} from './ssh-relay-upload-stage-contract' - -const OWNER_PATTERN = /^\.sftp-namespace-[0-9a-f]{32}$/u - -function assertOwner(owner: string): void { - if (!OWNER_PATTERN.test(owner)) { - throw new Error('Invalid relay upload stage owner') - } -} - -function slotPaths( - host: RemoteHostPlatform, - poolDir: string, - slotName: string -): RelayUploadStageSlot { - if (!/^slot-[0-7]$/u.test(slotName)) { - throw new Error('Invalid relay upload stage slot') - } - return { - poolDir, - slotName, - slotDir: joinRemotePath(host, poolDir, slotName), - claimDir: joinRemotePath(host, poolDir, `claim-${slotName.slice(5)}`), - deleteDir: joinRemotePath(host, poolDir, `delete-${slotName.slice(5)}`) - } -} - -export function reserveRelayUploadStageCommand( - host: RemoteHostPlatform, - poolDir: string, - owner: string, - identity?: WindowsUploadStageIdentity -): string { - assertOwner(owner) - return isWindowsRemoteHost(host) - ? reserveWindowsRelayUploadStageCommand(poolDir, owner, identity) - : reservePosixStageCommand(poolDir, owner) -} - -export function parseReservedRelayUploadStage( - host: RemoteHostPlatform, - poolDir: string, - owner: string, - output: string -): RelayUploadStageSlot { - assertOwner(owner) - const authenticatedPrefix = `${SLOT_RESULT_PREFIX}${owner}:` - const line = output - .split(/\r?\n/u) - .map((entry) => entry.trim()) - .find((entry) => entry.startsWith(authenticatedPrefix)) - if (!line) { - throw new Error('Remote relay upload stage reservation returned no authenticated slot') - } - return slotPaths(host, poolDir, line.slice(authenticatedPrefix.length)) -} - -export function promoteOwnedRelayUploadStageCommand( - host: RemoteHostPlatform, - stage: RelayUploadStageSlot, - owner: string, - destinationDir: string, - identity?: WindowsUploadStageIdentity -): string { - assertOwner(owner) - return isWindowsRemoteHost(host) - ? promoteWindowsRelayUploadStageCommand(stage, owner, destinationDir, identity) - : promotePosixStageCommand(stage, owner, destinationDir) -} - -export function relayUploadStagePromotionConfirmed(owner: string, output: string): boolean { - assertOwner(owner) - return output - .split(/\r?\n/u) - .some((line) => line.trim() === `${PROMOTION_RESULT_PREFIX}${owner}:PROMOTED`) -} - -export function cleanupOwnedRelayUploadStageCommand( - host: RemoteHostPlatform, - stage: RelayUploadStageSlot, - owner: string, - identity?: WindowsUploadStageIdentity -): string { - assertOwner(owner) - return isWindowsRemoteHost(host) - ? cleanupWindowsRelayUploadStageCommand(stage, owner, identity) - : cleanupPosixStageCommand(stage, owner) -} - -export function recoverOneStaleRelayUploadStageCommand( - host: RemoteHostPlatform, - poolDir: string, - staleSeconds = RELAY_UPLOAD_STAGE_STALE_SECONDS, - identity?: WindowsUploadStageIdentity -): string { - const cutoffSeconds = Math.max(1, Math.ceil(staleSeconds)) - return isWindowsRemoteHost(host) - ? recoverWindowsRelayUploadStageCommand(poolDir, cutoffSeconds, identity) - : recoverPosixStageCommand(poolDir, Math.ceil(cutoffSeconds / 60)) -} - -function reservePosixStageCommand(poolDir: string, owner: string): string { - const pool = shellEscape(poolDir) - const slots = Array.from({ length: RELAY_UPLOAD_STAGE_SLOT_COUNT }, (_, index) => index).join(' ') - return [ - 'umask 077;', - `pool=${pool};`, - 'mkdir -p "$pool" || exit 1;', - '[ -d "$pool" ] && [ ! -L "$pool" ] || exit 1;', - `for n in ${slots}; do`, - 'slot="$pool/slot-$n"; claim="$pool/claim-$n"; deleting="$pool/delete-$n";', - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] && mkdir "$slot" 2>/dev/null; then', - `identity=$(ls -id "$slot" 2>/dev/null | awk '{print $1}') || identity=;`, - `if [ -n "$identity" ] && mkdir "$slot/payload" && printf '%s' ${shellEscape(owner)} > "$slot/${OWNER_FILE_NAME}" && printf '%s' "$identity" > "$slot/${IDENTITY_FILE_NAME}" && : > "$slot/${owner}"; then`, - `printf '%s%s:%s\\n' ${shellEscape(SLOT_RESULT_PREFIX)} ${shellEscape(owner)} "slot-$n"; exit 0;`, - 'fi;', - 'fi;', - 'done;', - `printf '%s\\n' 'Orca relay upload staging quota is full; reconnect after 40 minutes or inspect .orca-remote/${RELAY_UPLOAD_STAGE_POOL_NAME}' >&2;`, - 'exit 75' - ].join(' ') -} - -function posixClaimPrelude(stage: RelayUploadStageSlot): string { - return [ - `pool=${shellEscape(stage.poolDir)}; slot=${shellEscape(stage.slotDir)}; claim=${shellEscape(stage.claimDir)}; deleting=${shellEscape(stage.deleteDir)};`, - '[ -d "$pool" ] && [ ! -L "$pool" ] || exit 0;', - '[ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] || exit 0;', - 'mv "$slot" "$claim" 2>/dev/null || exit 0;' - ].join(' ') -} - -function posixRestoreClaim(): string { - return 'if [ ! -e "$slot" ] && [ ! -L "$slot" ]; then mv "$claim" "$slot" 2>/dev/null || true; fi;' -} - -function posixOwnedDirectoryCheck( - pathVariable: string, - owner: string, - requirePayload: boolean -): string { - const path = `$${pathVariable}` - const checks = [ - `owner_file="${path}/${OWNER_FILE_NAME}";`, - `identity_file="${path}/${IDENTITY_FILE_NAME}";`, - `actual=$(cat "$owner_file" 2>/dev/null) || actual=;`, - `expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=;`, - `identity=$(ls -id "${path}" 2>/dev/null | awk '{print $1}') || identity=;`, - `[ -n "$identity" ] && [ "$identity" = "$expected_identity" ] && [ -d "${path}" ] && [ ! -L "${path}" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && [ "$actual" = ${shellEscape(owner)} ]` - ] - if (requirePayload) { - checks.push( - `&& payload="${path}/payload" && [ -d "$payload" ] && [ ! -L "$payload" ]`, - `&& payload_identity=$(ls -id "$payload" 2>/dev/null | awk '{print $1}') && [ -n "$payload_identity" ]`, - '&& links=$(find "$payload" -type l -print -quit 2>/dev/null) && [ -z "$links" ]' - ) - } - return checks.join(' ') -} - -function posixMoveOwnedClaimToDelete(owner: string): string { - return [ - '[ ! -e "$deleting" ] && [ ! -L "$deleting" ] || exit 0;', - 'mv "$claim" "$deleting" 2>/dev/null || exit 0;', - `if ${posixOwnedDirectoryCheck('deleting', owner, true)} && [ "$identity" = "$claim_identity" ]; then`, - 'rm -rf "$deleting";', - 'else', - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ]; then mv "$deleting" "$claim" 2>/dev/null || true; fi;', - 'fi;' - ].join(' ') -} - -function promotePosixStageCommand( - stage: RelayUploadStageSlot, - owner: string, - destinationDir: string -): string { - const expected = shellEscape(owner) - return [ - posixClaimPrelude(stage), - `if ! { ${posixOwnedDirectoryCheck('claim', owner, true)}; }; then`, - posixRestoreClaim(), - `printf '%s%s:OWNERSHIP_LOST\\n' ${shellEscape(PROMOTION_RESULT_PREFIX)} ${expected}; exit 0;`, - 'fi;', - 'claim_identity="$identity";', - `if (cd "$payload" && [ "$(ls -id . 2>/dev/null | awk '{print $1}')" = "$payload_identity" ] && cp -a . ${shellEscape(destinationDir)}/); then`, - posixMoveOwnedClaimToDelete(owner), - `printf '%s%s:PROMOTED\\n' ${shellEscape(PROMOTION_RESULT_PREFIX)} ${expected}; exit 0;`, - 'fi;', - 'exit 1' - ].join(' ') -} - -function cleanupPosixStageCommand(stage: RelayUploadStageSlot, owner: string): string { - return [ - posixClaimPrelude(stage), - `if ${posixOwnedDirectoryCheck('claim', owner, true)}; then`, - 'claim_identity="$identity";', - posixMoveOwnedClaimToDelete(owner), - 'else', - posixRestoreClaim(), - 'fi;', - 'exit 0' - ].join(' ') -} - -function recoverPosixStageCommand(poolDir: string, staleMinutes: number): string { - const pool = shellEscape(poolDir) - const slots = Array.from({ length: RELAY_UPLOAD_STAGE_SLOT_COUNT }, (_, index) => index).join(' ') - return [ - `pool=${pool}; [ -d "$pool" ] && [ ! -L "$pool" ] || exit 0;`, - `for n in ${slots}; do`, - 'slot="$pool/slot-$n"; claim="$pool/claim-$n"; deleting="$pool/delete-$n";', - `if [ -d "$deleting" ] && [ ! -L "$deleting" ]; then owner_file="$deleting/${OWNER_FILE_NAME}"; identity_file="$deleting/${IDENTITY_FILE_NAME}"; deleting_actual=$(cat "$owner_file" 2>/dev/null) || deleting_actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; deleting_identity=$(ls -id "$deleting" 2>/dev/null | awk '{print $1}') || deleting_identity=; deleting_old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || deleting_old=; if [ -n "$deleting_identity" ] && [ "$deleting_identity" = "$expected_identity" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$deleting_actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$deleting_old" ] && links=$(find "$deleting" -type l -print -quit 2>/dev/null) && [ -z "$links" ]; then rm -rf "$deleting"; exit 0; fi; fi;`, - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] && [ -d "$slot" ] && [ ! -L "$slot" ] &&', - `old=$(find "$slot/${OWNER_FILE_NAME}" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) && [ -n "$old" ]; then`, - 'mv "$slot" "$claim" 2>/dev/null || continue;', - 'fi;', - `owner_file="$claim/${OWNER_FILE_NAME}"; identity_file="$claim/${IDENTITY_FILE_NAME}"; actual=$(cat "$owner_file" 2>/dev/null) || actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; identity=$(ls -id "$claim" 2>/dev/null | awk '{print $1}') || identity=; old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || old=;`, - `if [ -n "$identity" ] && [ "$identity" = "$expected_identity" ] && [ -d "$claim" ] && [ ! -L "$claim" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$old" ]; then`, - 'claim_identity="$identity";', - '[ ! -e "$deleting" ] && [ ! -L "$deleting" ] && mv "$claim" "$deleting" 2>/dev/null || continue;', - `owner_file="$deleting/${OWNER_FILE_NAME}"; identity_file="$deleting/${IDENTITY_FILE_NAME}"; deleting_actual=$(cat "$owner_file" 2>/dev/null) || deleting_actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; deleting_identity=$(ls -id "$deleting" 2>/dev/null | awk '{print $1}') || deleting_identity=; deleting_old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || deleting_old=;`, - `if [ -n "$deleting_identity" ] && [ "$deleting_identity" = "$claim_identity" ] && [ "$deleting_identity" = "$expected_identity" ] && [ -d "$deleting" ] && [ ! -L "$deleting" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$deleting_actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$deleting_old" ] && links=$(find "$deleting" -type l -print -quit 2>/dev/null) && [ -z "$links" ]; then`, - 'rm -rf "$deleting"; exit 0;', - 'fi;', - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ]; then mv "$deleting" "$claim" 2>/dev/null || true; fi;', - 'continue;', - 'fi;', - posixRestoreClaim(), - 'done;', - 'exit 0' - ].join(' ') -} diff --git a/src/main/ssh/ssh-relay-upload-stage-contract.ts b/src/main/ssh/ssh-relay-upload-stage-contract.ts deleted file mode 100644 index 2fcc20c1381b..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-contract.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const RELAY_UPLOAD_STAGE_POOL_NAME = '.upload-stages' -export const RELAY_UPLOAD_STAGE_SLOT_COUNT = 8 -export const RELAY_UPLOAD_STAGE_STALE_SECONDS = 40 * 60 - -export const RELAY_UPLOAD_OWNER_FILE_NAME = '.orca-upload-owner' -export const RELAY_UPLOAD_IDENTITY_FILE_NAME = '.orca-upload-identity' -export const RELAY_UPLOAD_SLOT_RESULT_PREFIX = '__ORCA_UPLOAD_STAGE_SLOT__' -export const RELAY_UPLOAD_PROMOTION_RESULT_PREFIX = '__ORCA_UPLOAD_STAGE_PROMOTION__' - -export type RelayUploadStageSlot = { - claimDir: string - deleteDir: string - poolDir: string - slotDir: string - slotName: string -} diff --git a/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts b/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts deleted file mode 100644 index 811b29de5f4f..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' -import { - RELAY_UPLOAD_IDENTITY_FILE_NAME, - RELAY_UPLOAD_OWNER_FILE_NAME, - RELAY_UPLOAD_PROMOTION_RESULT_PREFIX, - RELAY_UPLOAD_SLOT_RESULT_PREFIX, - RELAY_UPLOAD_STAGE_POOL_NAME, - RELAY_UPLOAD_STAGE_SLOT_COUNT, - type RelayUploadStageSlot -} from './ssh-relay-upload-stage-contract' - -export function reserveWindowsRelayUploadStageCommand( - poolDir: string, - owner: string, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - `$pool = ${powerShellLiteral(poolDir)}`, - `$owner = ${powerShellLiteral(owner)}`, - '$null = New-Item -ItemType Directory -Force -Path $pool', - '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction Stop', - 'if (-not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw "Unsafe relay upload stage pool" }', - `foreach ($n in 0..${RELAY_UPLOAD_STAGE_SLOT_COUNT - 1}) {`, - '$slot = Join-Path $pool ("slot-" + $n)', - '$claim = Join-Path $pool ("claim-" + $n)', - '$deleting = Join-Path $pool ("delete-" + $n)', - '$slotExisting = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', - '$claimExisting = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', - '$deleteExisting = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - 'if (($null -ne $claimExisting) -or ($null -ne $slotExisting) -or ($null -ne $deleteExisting)) { continue }', - 'try {', - '$null = New-Item -ItemType Directory -Path $slot -ErrorAction Stop', - '$null = New-Item -ItemType Directory -Path (Join-Path $slot "payload") -ErrorAction Stop', - '$identity = & $getFileIdentity $slot', - `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}), $owner, [System.Text.UTF8Encoding]::new($false))`, - `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}), $identity, [System.Text.UTF8Encoding]::new($false))`, - `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(owner)}), '', [System.Text.UTF8Encoding]::new($false))`, - `Write-Output (${powerShellLiteral(RELAY_UPLOAD_SLOT_RESULT_PREFIX)} + $owner + ':slot-' + $n)`, - 'exit 0', - '} catch { continue }', - '}', - `throw ${powerShellLiteral(`Orca relay upload staging quota is full; reconnect after 40 minutes or inspect .orca-remote/${RELAY_UPLOAD_STAGE_POOL_NAME}`)}` - ].join('\n') - ) -} - -/** Where a Windows stage command reads file identities; `node` is a verified pinned node.exe. */ -export type WindowsUploadStageIdentity = { node?: string } - -/** - * `vol:indexHigh:indexLow` in lowercase hex, the format the legacy Add-Type helper persisted. - * libuv fills `dev`/`ino` from the same volume serial and file index, so either reader accepts - * the other's identity files. Single quotes only: PS 5.1 drops embedded double quotes from argv. - */ -export const WINDOWS_UPLOAD_STAGE_IDENTITY_JS = - "const s=require('fs').lstatSync(process.argv[1],{bigint:true});const m=0xffffffffn;" + - "process.stdout.write((s.dev&m).toString(16)+':'+(s.ino>>32n).toString(16)+':'+(s.ino&m).toString(16))" - -function windowsFileIdentityScript(identity: WindowsUploadStageIdentity = {}): string[] { - // Why normalize: a leading zero or upper-case digit from either reader is not a different file. - const normalize = - "function ConvertTo-OrcaFileIdentity([string]$value) { (($value.Trim().ToLowerInvariant() -split ':') | ForEach-Object { $digits = $_.TrimStart('0'); if ($digits -eq '') { '0' } else { $digits } }) -join ':' }" - if (identity.node) { - // Why node.exe and not Add-Type: runtime-compiled P/Invoke is an EDR signal (design D5). - return [ - normalize, - `$orcaIdentityNode = ${powerShellLiteral(identity.node)}`, - `$getFileIdentity = { param($path) $value = & $orcaIdentityNode -e ${powerShellNativeArg(WINDOWS_UPLOAD_STAGE_IDENTITY_JS)} -- $path; if ($LASTEXITCODE -ne 0) { throw 'Orca could not read a relay upload stage file identity' }; ConvertTo-OrcaFileIdentity ([string]$value) }` - ] - } - // Legacy: host-Node relays have no verified node.exe to run; see windows-edr-posture.md. - return [ - normalize, - "if ($env:OS -eq 'Windows_NT') {", - "if ($null -eq ('OrcaRelayUploadFileIdentity' -as [type])) {", - "Add-Type -TypeDefinition @'", - 'using System;', - 'using System.ComponentModel;', - 'using System.Runtime.InteropServices;', - 'using Microsoft.Win32.SafeHandles;', - 'public static class OrcaRelayUploadFileIdentity {', - '[StructLayout(LayoutKind.Sequential)] struct Info { public uint Attr; public System.Runtime.InteropServices.ComTypes.FILETIME C; public System.Runtime.InteropServices.ComTypes.FILETIME A; public System.Runtime.InteropServices.ComTypes.FILETIME W; public uint Vol; public uint SizeH; public uint SizeL; public uint Links; public uint IndexH; public uint IndexL; }', - '[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr sec, uint creation, uint flags, IntPtr template);', - '[DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle handle, out Info info);', - 'public static string Read(string path) {', - 'using (var handle = CreateFileW(path, 0, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) {', - 'if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());', - 'Info info; if (!GetFileInformationByHandle(handle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error());', - 'return info.Vol.ToString("x") + ":" + info.IndexH.ToString("x") + ":" + info.IndexL.ToString("x");', - '}', - '}', - '}', - "'@", - '}', - '$getFileIdentity = { param($path) ConvertTo-OrcaFileIdentity ([OrcaRelayUploadFileIdentity]::Read($path)) }', - '} else {', - '$getFileIdentity = { param($path) $resolved = (Get-Item -LiteralPath $path -Force -ErrorAction Stop).FullName; $value = & /usr/bin/stat -f "%i" -- $resolved 2>$null; if ($LASTEXITCODE -ne 0) { $value = & /usr/bin/stat -c "%i" -- $resolved }; ConvertTo-OrcaFileIdentity ([string]$value) }', - '}' - ] -} - -function windowsClaimPrelude(stage: RelayUploadStageSlot): string[] { - return [ - `$slot = ${powerShellLiteral(stage.slotDir)}`, - `$claim = ${powerShellLiteral(stage.claimDir)}`, - `$deleting = ${powerShellLiteral(stage.deleteDir)}`, - `$pool = ${powerShellLiteral(stage.poolDir)}`, - '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction SilentlyContinue', - 'if (($null -eq $poolItem) -or -not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 0 }', - '$claimBefore = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', - '$deleteBefore = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - '$slotBefore = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', - 'if (($null -ne $claimBefore) -or ($null -ne $deleteBefore) -or ($null -eq $slotBefore)) { exit 0 }', - 'Move-Item -LiteralPath $slot -Destination $claim -ErrorAction Stop' - ] -} - -function windowsOwnershipScript( - owner: string, - requirePayload: boolean, - pathExpression = '$claim' -): string[] { - return [ - `$expectedOwner = ${powerShellLiteral(owner)}`, - `$ownedPath = ${pathExpression}`, - `$ownerPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, - `$identityPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}`, - '$claimItem = Get-Item -LiteralPath $ownedPath -Force -ErrorAction SilentlyContinue', - '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', - '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', - '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', - '$expectedIdentity = if ($null -ne $identityItem) { ConvertTo-OrcaFileIdentity ([System.IO.File]::ReadAllText($identityPath)) } else { "" }', - '$actualIdentity = if ($null -ne $claimItem) { & $getFileIdentity $ownedPath } else { "" }', - '$owned = ($null -ne $claimItem) -and $claimItem.PSIsContainer -and (($claimItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualOwner -ceq $expectedOwner) -and ($actualIdentity -ceq $expectedIdentity)', - ...(requirePayload - ? [ - '$payload = Join-Path $ownedPath "payload"', - '$payloadItem = Get-Item -LiteralPath $payload -Force -ErrorAction SilentlyContinue', - '$reparse = $null', - 'if (($null -ne $payloadItem) -and $payloadItem.PSIsContainer -and (($payloadItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) {', - '$reparse = Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1', - '}', - '$owned = $owned -and ($null -ne $payloadItem) -and $payloadItem.PSIsContainer -and (($payloadItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -eq $reparse)' - ] - : []) - ] -} - -function windowsRestoreClaim(): string { - return 'if (($null -eq (Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue))) { Move-Item -LiteralPath $claim -Destination $slot -ErrorAction SilentlyContinue }' -} - -function windowsStaleOwnershipScript(pathExpression: string): string[] { - return [ - `$ownedPath = ${pathExpression}`, - `$ownerPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, - `$identityPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}`, - '$ownedItem = Get-Item -LiteralPath $ownedPath -Force -ErrorAction SilentlyContinue', - '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', - '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', - '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', - '$expectedIdentity = if ($null -ne $identityItem) { ConvertTo-OrcaFileIdentity ([System.IO.File]::ReadAllText($identityPath)) } else { "" }', - '$actualIdentity = if ($null -ne $ownedItem) { & $getFileIdentity $ownedPath } else { "" }', - "$owned = ($null -ne $ownedItem) -and $ownedItem.PSIsContainer -and (($ownedItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualIdentity -ceq $expectedIdentity) -and ($ownerItem.LastWriteTimeUtc -lt $cutoff) -and ($actualOwner -match '^\\.sftp-namespace-[0-9a-f]{32}$')", - '$reparse = $null', - 'if ($owned) {', - '$reparse = Get-ChildItem -LiteralPath $ownedPath -Force -Recurse -ErrorAction Stop | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1', - '}', - '$owned = $owned -and ($null -eq $reparse)' - ] -} - -function windowsDeleteOwnedClaimScript(owner: string): string[] { - return [ - '$deleteBefore = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - 'if ($null -ne $deleteBefore) { exit 0 }', - 'Move-Item -LiteralPath $claim -Destination $deleting -ErrorAction Stop', - ...windowsOwnershipScript(owner, true, '$deleting'), - 'if ($owned) {', - 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', - '} elseif (($null -eq (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) {', - 'Move-Item -LiteralPath $deleting -Destination $claim -ErrorAction SilentlyContinue', - '}' - ] -} - -export function promoteWindowsRelayUploadStageCommand( - stage: RelayUploadStageSlot, - owner: string, - destinationDir: string, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - ...windowsClaimPrelude(stage), - ...windowsOwnershipScript(owner, true), - 'if (-not $owned) {', - windowsRestoreClaim(), - `Write-Output (${powerShellLiteral(RELAY_UPLOAD_PROMOTION_RESULT_PREFIX)} + $expectedOwner + ':OWNERSHIP_LOST')`, - 'exit 0', - '}', - `Get-ChildItem -LiteralPath $payload -Force -ErrorAction Stop | Copy-Item -Destination ${powerShellLiteral(destinationDir)} -Recurse -Force -ErrorAction Stop`, - ...windowsDeleteOwnedClaimScript(owner), - `Write-Output (${powerShellLiteral(RELAY_UPLOAD_PROMOTION_RESULT_PREFIX)} + $expectedOwner + ':PROMOTED')` - ].join('\n') - ) -} - -export function cleanupWindowsRelayUploadStageCommand( - stage: RelayUploadStageSlot, - owner: string, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - ...windowsClaimPrelude(stage), - ...windowsOwnershipScript(owner, true), - 'if ($owned) {', - ...windowsDeleteOwnedClaimScript(owner), - '} else {', - windowsRestoreClaim(), - '}' - ].join('\n') - ) -} - -export function recoverWindowsRelayUploadStageCommand( - poolDir: string, - staleSeconds: number, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - `$pool = ${powerShellLiteral(poolDir)}`, - `$cutoff = [DateTime]::UtcNow.AddSeconds(-${staleSeconds})`, - '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction SilentlyContinue', - 'if (($null -eq $poolItem) -or -not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 0 }', - `foreach ($n in 0..${RELAY_UPLOAD_STAGE_SLOT_COUNT - 1}) {`, - '$slot = Join-Path $pool ("slot-" + $n)', - '$claim = Join-Path $pool ("claim-" + $n)', - '$deleting = Join-Path $pool ("delete-" + $n)', - ...windowsStaleOwnershipScript('$deleting'), - 'if ($owned) {', - 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', - 'exit 0', - '}', - `$slotOwner = Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, - '$slotItem = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', - '$slotOwnerItem = Get-Item -LiteralPath $slotOwner -Force -ErrorAction SilentlyContinue', - '$claimItem = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', - '$deleteItem = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - 'if (($null -eq $claimItem) -and ($null -eq $deleteItem) -and ($null -ne $slotItem) -and $slotItem.PSIsContainer -and (($slotItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $slotOwnerItem) -and -not $slotOwnerItem.PSIsContainer -and (($slotOwnerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and $slotOwnerItem.LastWriteTimeUtc -lt $cutoff) {', - 'Move-Item -LiteralPath $slot -Destination $claim -ErrorAction SilentlyContinue', - '}', - ...windowsStaleOwnershipScript('$claim'), - 'if ($owned -and ($null -eq (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) {', - 'Move-Item -LiteralPath $claim -Destination $deleting -ErrorAction Stop', - ...windowsStaleOwnershipScript('$deleting'), - 'if (-not $owned) {', - 'if (($null -eq (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) { Move-Item -LiteralPath $deleting -Destination $claim -ErrorAction SilentlyContinue }', - 'continue', - '}', - 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', - 'exit 0', - '}', - windowsRestoreClaim(), - '}' - ].join('\n') - ) -} diff --git a/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts b/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts deleted file mode 100644 index a61081daad34..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Upload-stage file identity on Windows through a verified pinned node.exe instead of the legacy - * Add-Type helper (design D5, docs/reference/windows-edr-posture.md). - */ -import { spawnSync } from 'node:child_process' -import { - cpSync, - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - renameSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - promoteOwnedRelayUploadStageCommand, - recoverOneStaleRelayUploadStageCommand, - relayUploadStagePromotionConfirmed, - reserveRelayUploadStageCommand, - type WindowsUploadStageIdentity -} from './ssh-relay-upload-stage-commands' -import { WINDOWS_UPLOAD_STAGE_IDENTITY_JS } from './ssh-relay-upload-stage-windows-commands' -import { removeTreeSync } from '../../shared/windows-transient-lock-removal' - -const windows = getRemoteHostPlatform('win32-x64') -const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' -const pool = 'C:/Users/ada/.orca-remote/.upload-stages' -const pinned: WindowsUploadStageIdentity = { - node: 'C:/Users/ada/.orca-remote/runtimes/node-abc/node.exe' -} -const stage = parseReservedRelayUploadStage( - windows, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-3` -) -const roots: string[] = [] - -function allCommands(identity?: WindowsUploadStageIdentity): string[] { - return [ - reserveRelayUploadStageCommand(windows, pool, owner, identity), - promoteOwnedRelayUploadStageCommand(windows, stage, owner, `${pool}/../relay-x`, identity), - cleanupOwnedRelayUploadStageCommand(windows, stage, owner, identity), - recoverOneStaleRelayUploadStageCommand(windows, pool, undefined, identity) - ].map(decodeRemotePowerShellScript) -} - -function tempDir(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-stage-identity-')) - roots.push(root) - return root -} - -function nodeIdentity(path: string): string { - const result = spawnSync(process.execPath, ['-e', WINDOWS_UPLOAD_STAGE_IDENTITY_JS, '--', path], { - encoding: 'utf8' - }) - expect(result.status, result.stderr).toBe(0) - return result.stdout -} - -afterEach(() => { - for (const root of roots.splice(0)) { - removeTreeSync(root) - } -}) - -describe('Windows upload-stage identity commands', () => { - it('compile nothing once a verified node.exe is known', () => { - for (const script of allCommands(pinned)) { - expect(script).not.toMatch(/Add-Type|DllImport|ExecutionPolicy|\.ps1/) - expect(script).toContain(`$orcaIdentityNode = '${pinned.node}'`) - } - }) - - it('keep the Add-Type helper only for relays with no verified node.exe', () => { - for (const script of allCommands()) { - expect(script).toContain('Add-Type -TypeDefinition') - expect(script).not.toContain('$orcaIdentityNode') - } - }) - - it('run node.exe with the fixed script and the path as an argument', () => { - const script = allCommands(pinned)[0] - const lines = script.split('\n').filter((line) => line.includes('OrcaFileIdentity')) - expect(lines).toMatchInlineSnapshot(` - [ - "function ConvertTo-OrcaFileIdentity([string]$value) { (($value.Trim().ToLowerInvariant() -split ':') | ForEach-Object { $digits = $_.TrimStart('0'); if ($digits -eq '') { '0' } else { $digits } }) -join ':' }", - "$getFileIdentity = { param($path) $value = & $orcaIdentityNode -e 'const s=require(''fs'').lstatSync(process.argv[1],{bigint:true});const m=0xffffffffn;process.stdout.write((s.dev&m).toString(16)+'':''+(s.ino>>32n).toString(16)+'':''+(s.ino&m).toString(16))' -- $path; if ($LASTEXITCODE -ne 0) { throw 'Orca could not read a relay upload stage file identity' }; ConvertTo-OrcaFileIdentity ([string]$value) }", - ] - `) - }) -}) - -describe('the pinned node.exe identity script', () => { - it("prints the legacy helper's vol:indexHigh:indexLow lowercase hex", () => { - const dir = tempDir() - const stats = lstatSync(dir, { bigint: true }) - const mask = 0xffffffffn - expect(nodeIdentity(dir)).toBe( - `${(stats.dev & mask).toString(16)}:${(stats.ino >> 32n).toString(16)}:${(stats.ino & mask).toString(16)}` - ) - expect(nodeIdentity(dir)).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/) - }) - - it('survives the claim rename but not a copy with the same contents', () => { - const root = tempDir() - const slot = join(root, 'slot-0') - mkdirSync(slot) - const before = nodeIdentity(slot) - renameSync(slot, join(root, 'claim-0')) - expect(nodeIdentity(join(root, 'claim-0'))).toBe(before) - cpSync(join(root, 'claim-0'), join(root, 'copy'), { recursive: true }) - expect(nodeIdentity(join(root, 'copy'))).not.toBe(before) - }) -}) - -const powerShell = [ - process.env.ORCA_POWERSHELL_EXECUTABLE, - ...(process.platform === 'win32' ? ['powershell.exe', 'pwsh.exe'] : ['pwsh']) -].find( - (candidate) => - candidate && - spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { - stdio: 'ignore' - }).status === 0 -) - -function runPowerShell(command: string): { status: number | null; stdout: string } { - const result = spawnSync( - powerShell!, - ['-NoProfile', '-NonInteractive', '-Command', decodeRemotePowerShellScript(command)], - { encoding: 'utf8' } - ) - expect(result.status, result.stderr).toBe(0) - return result -} - -function reserve(localPool: string, identity?: WindowsUploadStageIdentity): string { - const out = runPowerShell(reserveRelayUploadStageCommand(windows, localPool, owner, identity)) - const reserved = parseReservedRelayUploadStage(windows, localPool, owner, out.stdout) - writeFileSync(join(reserved.slotDir, 'payload', 'relay.js'), 'relay') - return reserved.slotName -} - -function promote(localPool: string, slotName: string, identity?: WindowsUploadStageIdentity) { - const destination = join(localPool, '..', 'relay-x') - mkdirSync(destination, { recursive: true }) - const reserved = parseReservedRelayUploadStage( - windows, - localPool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:${slotName}` - ) - const out = runPowerShell( - promoteOwnedRelayUploadStageCommand(windows, reserved, owner, destination, identity) - ) - return { - promoted: relayUploadStagePromotionConfirmed(owner, out.stdout), - copied: existsSync(join(destination, 'relay.js')) - } -} - -describe.runIf(powerShell)('Windows upload-stage identity (real PowerShell)', () => { - const node = { node: process.execPath } - - it('reserves and promotes through node.exe alone', { timeout: 120_000 }, () => { - const localPool = join(tempDir(), 'pool') - const slot = reserve(localPool, node) - expect(promote(localPool, slot, node)).toEqual({ promoted: true, copied: true }) - }) - - it('accepts an identity file in any hex spelling of the same file', { timeout: 120_000 }, () => { - const localPool = join(tempDir(), 'pool') - const slot = reserve(localPool, node) - const identityFile = join(localPool, slot, '.orca-upload-identity') - const [vol, high, low] = readFileSync(identityFile, 'utf8').split(':') - writeFileSync(identityFile, `${vol.toUpperCase()}:000${high}:${low.toUpperCase()}\r\n`) - expect(promote(localPool, slot, node)).toEqual({ promoted: true, copied: true }) - }) - - // Why Windows only: off Windows the legacy branch reads `stat %i`, a different format. - it.runIf(process.platform === 'win32')( - 'reads identity files the legacy Add-Type helper wrote, and the reverse', - { timeout: 240_000 }, - () => { - const oldToNew = join(tempDir(), 'pool') - expect(promote(oldToNew, reserve(oldToNew), node)).toEqual({ promoted: true, copied: true }) - const newToOld = join(tempDir(), 'pool') - expect(promote(newToOld, reserve(newToOld, node))).toEqual({ promoted: true, copied: true }) - } - ) -}) diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 0dc2f428df95..a3b697ec083a 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -30,7 +30,6 @@ import { gcOldRelayVersions } from './ssh-relay-versioned-install' import { acquireInstallLock } from './ssh-relay-install-lock' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { isRelayGcClaimed, relayGcClaimPath, @@ -179,84 +178,6 @@ describe('acquireInstallLock', () => { expect(commands[4]).toBe("mkdir -p '/r'") }) - it('returns immediately without deleting a live repair lock', async () => { - // Why: repair can run npm install plus rebuild under the same lock; a - // second reconnect must launch degraded rather than corrupt node_modules. - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('LOCKED') - .mockResolvedValueOnce('0') - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('busy') - - const commands = mockExec.mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) - expect(commands.filter((command) => command.startsWith('rm -rf'))).toHaveLength(0) - }) - - it('does not report stale or indeterminate contention as a launch fence', async () => { - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('LOCKED') - .mockResolvedValueOnce(`${21 * 60}`) - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') - - mockExec.mockReset().mockRejectedValueOnce(new Error('claim probe failed')) - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') - }) - - it('recovers a stale best-effort repair lock without polling', async () => { - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OK') - .mockResolvedValueOnce('OPEN') - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('acquired') - - const commands = mockExec.mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) - expect(commands.filter((command) => command.includes('lock_tombstone'))).toHaveLength(1) - }) - - it('backs out when GC claims the sibling path during repair lock acquisition', async () => { - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('OK') - .mockResolvedValueOnce('LOCKED') - .mockResolvedValueOnce('') - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('gc') - - const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' - expect(lastCommand).toBe("rm -rf '/r/.install-lock'") - }) - - it('propagates cancellation through best-effort repair lock commands', async () => { - const abortController = new AbortController() - const abortError = Object.assign(new Error('cancelled'), { name: 'AbortError' }) - mockExec.mockRejectedValueOnce(abortError) - - const promise = tryAcquireRelayRepairLock(conn, '/r', undefined, { - signal: abortController.signal - }) - abortController.abort() - - await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) - expect(mockExec.mock.calls[0]?.[2]?.signal).toBe(abortController.signal) - }) - it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-windows-host-lane.test.ts b/src/main/ssh/ssh-relay-windows-host-lane.test.ts deleted file mode 100644 index c772b90e0511..000000000000 --- a/src/main/ssh/ssh-relay-windows-host-lane.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// Design D5/D6 Windows SSH-host lane: the real client-side relay deploy against a real -// Win32-OpenSSH server on 127.0.0.1 (inbox capability or the pinned preview release), one cell -// per run. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 provisions the account, -// sets DefaultShell, writes the descriptor and runs this file; ssh-windows-hosts.yml runs that. -// -// Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL= pnpm test -import { randomUUID } from 'node:crypto' -import { writeFileSync } from 'node:fs' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { ORCAD_RUNTIMES_DIRNAME } from '../../shared/orcad-artifacts' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { WINDOWS_RELAY_LAUNCH_LOG_PREFIX } from './ssh-relay-windows-launch-command' -import type { SshConnection } from './ssh-connection' -import { localHostObserver } from './ssh-hostile-host-observer' -import { - assertCell, - connectHostileHost, - deployErrorText, - deployOnce, - exerciseLaunchedCell, - installHostileHostAppEnvironment, - TERMINAL_PROBES, - type DeployAttempt -} from './ssh-hostile-host-test-harness' -import { - auditWindowsSessionCommands, - windowsSessionCommandViolations, - type WindowsSessionCommandAudit -} from './ssh-session-command-audit' -import { - isWindowsHostCellId, - readWindowsHostCellDescriptor, - windowsHostCell, - windowsHostSshTarget -} from './ssh-windows-host-cells' - -const RUN = process.env.ORCA_RUN_SSH_WINDOWS_HOST === '1' -const CELL_TIMEOUT_MS = 20 * 60_000 - -describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => { - let cleanupAppEnvironment: (() => void) | null = null - - beforeAll(() => { - cleanupAppEnvironment = installHostileHostAppEnvironment() - }) - - afterAll(() => { - cleanupAppEnvironment?.() - }) - - it( - 'lands the cell the descriptor names', - async () => { - const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') - if (!isWindowsHostCellId(descriptor.cell)) { - throw new Error(`${descriptor.cell} does not run in the relay lane`) - } - const cell = windowsHostCell(descriptor.cell, descriptor.target) - const observer = localHostObserver(descriptor.forbiddenToolLog) - const sshTarget = windowsHostSshTarget(descriptor, cell, randomUUID()) - const audits: (WindowsSessionCommandAudit & { uploaded: boolean })[] = [] - const inspectDeploy = (attempt: DeployAttempt, uploaded: boolean): void => { - const audit = auditWindowsSessionCommands(attempt.commands) - audits.push({ ...audit, uploaded }) - const violations = windowsSessionCommandViolations(audit, { uploaded }) - expect(violations, `${cell.id}: ${violations.join('; ')}`).toEqual([]) - } - const receipt: Record = { - cell: cell.id, - target: descriptor.target, - audits - } - // The deploy logs each relay launch; the cell reads them to prove which route ran. - const launches: unknown[] = [] - const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - const line = args.map(String).join(' ') - if (line.startsWith(WINDOWS_RELAY_LAUNCH_LOG_PREFIX)) { - launches.push(JSON.parse(line.slice(WINDOWS_RELAY_LAUNCH_LOG_PREFIX.length))) - } - process.stdout.write(`${line}\n`) - }) - receipt.launches = launches - let conn: SshConnection | null = null - try { - conn = await connectHostileHost(sshTarget) - const first = await deployOnce(conn) - Object.assign(receipt, { - settledRung: first.settledRung, - refusals: first.refusals, - selfTest: first.run?.selfTest ?? null, - runtimeTransfer: first.run?.runtimeTransfer ?? null, - deployError: deployErrorText(first) - }) - await assertCell(cell, observer, first) - if (cell.expect.outcome === 'launched') { - const evidence = await exerciseLaunchedCell({ - cell, - observer, - sshTarget, - terminal: TERMINAL_PROBES.windows, - first, - firstConn: conn, - inspectDeploy - }) - Object.assign(receipt, evidence, { reused: true, gcKeptInUse: true }) - // One launch, outside sshd's job with no WMI grant; the second connect launched nothing, - // so it adopted the relay that outlived the first SSH connection. - expect(launches).toEqual([{ method: 'breakaway', pid: expect.any(Number), inJob: false }]) - } else { - // Opted out: nothing may enter the pinned runtime store, whatever the host-Node path did. - const store = `${descriptor.home}/${RELAY_REMOTE_DIR}/${ORCAD_RUNTIMES_DIRNAME}` - expect(await observer.exists(store)).toBe(false) - receipt.pinnedStoreUntouched = true - } - receipt.passed = true - } finally { - logSpy.mockRestore() - await conn?.disconnect().catch(() => {}) - writeFileSync(descriptor.receipt, `${JSON.stringify(receipt, null, 2)}\n`) - } - }, - CELL_TIMEOUT_MS - ) -}) diff --git a/src/main/ssh/ssh-remote-cli-args.ts b/src/main/ssh/ssh-remote-cli-args.ts deleted file mode 100644 index 4a2e0779cf34..000000000000 --- a/src/main/ssh/ssh-remote-cli-args.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { CLI_BOOLEAN_FLAGS } from '../../shared/cli-argument-boundary' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { - isOrchestrationRetryRequestId, - RETRY_REQUEST_ID_GUIDANCE, - VALUELESS_RETRY_REQUEST_GUIDANCE -} from '../../shared/orchestration-retry-request-id' - -const REPEATED_FLAG_SEPARATOR = '\u0000' -const REPEATABLE_REMOTE_STRING_FLAGS = new Set(['label']) - -export function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli { - const commandPath: string[] = [] - const flags = new Map() - for (let i = 0; i < argv.length; i += 1) { - const token = argv[i] - if (!token.startsWith('--')) { - commandPath.push(token) - continue - } - const assignment = token.slice(2) - // Why: the SSH relay-backed shim should accept values beginning with `--` via `--flag=value`. - const equalsIndex = assignment.indexOf('=') - if (equalsIndex !== -1) { - setRemoteFlag(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)) - continue - } - - const flag = assignment - const next = argv[i + 1] - if (!isRemoteBooleanFlag(flag, commandPath) && next && !next.startsWith('--')) { - setRemoteFlag(flags, flag, next) - i += 1 - } else { - setRemoteFlag(flags, flag, true) - } - } - return { commandPath, flags } -} - -export function resolveRemoteCliHandle( - flags: Map, - env: Record, - flagName: string -): string { - return optionalRemoteCliString(flags, flagName) ?? env.ORCA_TERMINAL_HANDLE ?? 'unknown' -} - -export function requiredRemoteCliString( - flags: Map, - name: string -): string { - const value = optionalRemoteCliString(flags, name) - if (!value) { - throw new Error(`Missing --${name}`) - } - return value -} - -export function optionalRemoteCliString( - flags: Map, - name: string -): string | undefined { - const value = flags.get(name) - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -/** - * The relay shim parses its own argv, so it cannot inherit the CLI's valued-flag guards. A - * `--retry-request` the shell emptied parses as `true`, and letting that fall through to - * `undefined` mints a fresh mutation identity and re-applies the mutation (#15180). - */ -export function readRemoteRetryRequestFlag( - flags: Map -): string | undefined { - const value = flags.get('retry-request') - if (value === undefined) { - return undefined - } - if (value === true) { - throw new RemoteCliArgumentError('invalid_argument', VALUELESS_RETRY_REQUEST_GUIDANCE) - } - if (!isOrchestrationRetryRequestId(value)) { - throw new RemoteCliArgumentError('invalid_argument', RETRY_REQUEST_ID_GUIDANCE) - } - return value -} - -export function optionalRemoteCliNumber( - flags: Map, - name: string -): number | undefined { - const value = optionalRemoteCliString(flags, name) - if (value === undefined) { - return undefined - } - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`) - } - return parsed -} - -function isRemoteBooleanFlag(flag: string, commandPath: string[]): boolean { - // Why: Android launch already uses --activity ; only Linear issue reads use it as a boolean. - return ( - CLI_BOOLEAN_FLAGS.has(flag) || - (flag === 'activity' && commandPath[0] === 'linear' && commandPath[1] === 'issue') - ) -} - -function setRemoteFlag( - flags: Map, - name: string, - value: string | boolean -): void { - const previous = flags.get(name) - if ( - typeof previous === 'string' && - typeof value === 'string' && - REPEATABLE_REMOTE_STRING_FLAGS.has(name) - ) { - flags.set(name, `${previous}${REPEATED_FLAG_SEPARATOR}${value}`) - return - } - flags.set(name, value) -} diff --git a/src/main/ssh/ssh-remote-cli-argument-error.ts b/src/main/ssh/ssh-remote-cli-argument-error.ts deleted file mode 100644 index 25d67c2d2749..000000000000 --- a/src/main/ssh/ssh-remote-cli-argument-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type ParsedRemoteCli = { - commandPath: string[] - flags: Map -} - -export class RemoteCliArgumentError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.name = 'RemoteCliArgumentError' - this.code = code - } -} diff --git a/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts b/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts deleted file mode 100644 index a8105f141e82..000000000000 --- a/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' -import { SshBridgeCredentialRegistry } from '../runtime/rpc/ssh-bridge-credentials' -import { runHostOrcaCliPassthrough } from './ssh-remote-cli-host-passthrough' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import { HOST_BOUND_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -class FakeChild extends EventEmitter { - readonly stdout = new EventEmitter() - readonly stderr = new EventEmitter() - readonly stdin = { end: vi.fn(), on: vi.fn() } - readonly kill = vi.fn() -} - -const RUNTIME_AUTHORITY = { - kind: 'ssh', - targetId: 'box-1', - connectionIncarnation: 'incarnation-1', - attachmentId: 'attachment-1' -} as const - -describe('the SSH bridge CLI child', () => { - it('holds a credential scoped to its SSH target, never the owner token, for its lifetime only', async () => { - const credentials = new SshBridgeCredentialRegistry() - const child = new FakeChild() - let spawnedEnv: NodeJS.ProcessEnv = {} - const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { - spawnedEnv = options.env - return child - }) - - const pending = runHostOrcaCliPassthrough( - { - argv: ['terminal', 'list', '--json'], - cwd: '/home/alice/wt', - env: { ORCA_ENVIRONMENT: 'prod' }, - runtimeAuthority: RUNTIME_AUTHORITY, - callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE - }, - { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true, - hostEnv: { - PATH: '/usr/bin', - ORCA_ENVIRONMENT: 'owner-paired-server', - ORCA_PAIRING_CODE: 'owner-pairing-code', - [ORCA_SSH_BRIDGE_CREDENTIAL_ENV]: 'stale-or-forged' - }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. - spawn: spawn as never, - credentials - } - ) - - const token = spawnedEnv[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] - expect(token).toMatch(/^sshb_[0-9a-f]{48}$/) - expect(credentials.resolve(token ?? '')).toEqual(HOST_BOUND_SSH_BRIDGE_SCOPE) - // Why: this machine's paired-server selection would route the guest's command with owner credentials. - expect(spawnedEnv.ORCA_ENVIRONMENT).toBeUndefined() - expect(spawnedEnv.ORCA_PAIRING_CODE).toBeUndefined() - - child.emit('close', 0) - await pending - expect(credentials.resolve(token ?? '')).toBeNull() - }) - - it('revokes the credential when the child cannot be launched', async () => { - const credentials = new SshBridgeCredentialRegistry() - const child = new FakeChild() - let token = '' - const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { - token = options.env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] ?? '' - return child - }) - const pending = runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {}, callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE }, - { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true, - hostEnv: {}, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. - spawn: spawn as never, - credentials - } - ) - child.emit('error', new Error('ENOENT')) - await expect(pending).rejects.toThrow('Failed to launch') - expect(credentials.resolve(token)).toBeNull() - }) -}) - -describe('the in-process fallback bridge', () => { - const LEGACY_FALLBACK_OPTIONS = { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => false - } - - it('applies the host-bound scope to orchestration it would otherwise relay as owner', async () => { - const runtime = new OrcaRuntimeService() - const result = await runRemoteOrcaCli( - runtime, - { - argv: ['orchestration', 'send', '--to', 'term_local', '--subject', 'hi', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_remote' }, - callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE - }, - LEGACY_FALLBACK_OPTIONS - ) - expect(result.exitCode).toBe(1) - expect(JSON.parse(result.stdout).error.code).toBe('forbidden') - }) - - it("filters the fallback terminal list to the bridged host's terminals", async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [ - { - handle: 'term_local', - ptyId: 'pty-1', - worktreeId: 'repo::/Users/me/secret', - worktreePath: '/Users/me/secret', - branch: 'main', - tabId: 'tab-1', - leafId: 'leaf-1', - title: 'owner shell', - connected: true, - writable: true, - lastOutputAt: null, - preview: 'owner secrets', - executionHostId: 'local' - } - ], - totalCount: 1, - truncated: false - }) - const result = await runRemoteOrcaCli( - runtime, - { - argv: ['terminal', 'list', '--json'], - cwd: '/home/alice/repo', - env: {}, - callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE - }, - LEGACY_FALLBACK_OPTIONS - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).not.toContain('owner secrets') - expect(JSON.parse(result.stdout).result.terminals).toEqual([]) - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts deleted file mode 100644 index b54a855596d8..000000000000 --- a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -// Why: the SSH bridge captures the host CLI child's stdout and exit code without reparsing; this -// pins that a typed refusal envelope and its nonzero exit reach the remote agent unchanged. -it('relays typed dispatch refusal codes from the host CLI unchanged', async () => { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - stdin: { end: ReturnType; on: ReturnType } - kill: ReturnType - } - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - child.stdin = { end: vi.fn(), on: vi.fn() } - child.kill = vi.fn() - const spawn = vi.fn(() => child) - const refusal = { - id: 'rpc_1', - ok: false, - error: { - code: 'task_not_startable', - message: 'Task task_1 is pending; only ready tasks can be dispatched', - data: { taskId: 'task_1', status: 'pending', unmetDependencies: ['task_0'] } - }, - _meta: { runtimeId: 'runtime_1' } - } - - const resultPromise = runRemoteOrcaCli( - new OrcaRuntimeService(), - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_w', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }, - { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true, - spawn: spawn as never - } - ) - - const stdout = `${JSON.stringify(refusal, null, 2)}\n` - await Promise.resolve() - child.stdout.emit('data', Buffer.from(stdout)) - child.emit('close', 1) - - expect(await resultPromise).toEqual({ stdout, stderr: '', exitCode: 1 }) -}) diff --git a/src/main/ssh/ssh-remote-cli-error-response.ts b/src/main/ssh/ssh-remote-cli-error-response.ts deleted file mode 100644 index dacf9402a451..000000000000 --- a/src/main/ssh/ssh-remote-cli-error-response.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { RpcResponse } from '../runtime/rpc/core' - -export function buildRemoteCliError(message: string, code = 'runtime_error'): RpcResponse { - return { - id: 'remote-cli-local', - ok: false, - error: { code, message }, - _meta: { runtimeId: 'unknown' } - } -} diff --git a/src/main/ssh/ssh-remote-cli-format.test.ts b/src/main/ssh/ssh-remote-cli-format.test.ts deleted file mode 100644 index 31f71caef274..000000000000 --- a/src/main/ssh/ssh-remote-cli-format.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { RpcResponse } from '../runtime/rpc/core' -import { formatRemoteCli } from './ssh-remote-cli-format' - -const meta = { runtimeId: 'runtime-test' } - -describe('formatRemoteCli', () => { - it('falls back to JSON for malformed Linear issue results', () => { - const response: RpcResponse = { - id: 'rpc-1', - ok: true, - _meta: meta, - result: { - issue: { - identifier: 'ENG-123', - title: 'Fix thing', - url: 'https://linear.app/acme/issue/ENG-123', - labels: [] - }, - meta: { - includeErrors: null, - sections: {} - } - } - } - - expect(formatRemoteCli(response)).toEqual({ - stdout: `${JSON.stringify(response.result)}\n`, - stderr: '' - }) - }) - - it('falls back to JSON for malformed Linear search results', () => { - const response: RpcResponse = { - id: 'rpc-1', - ok: true, - _meta: meta, - result: { - issues: [], - meta: { - query: 'auth', - returned: '0' - } - } - } - - expect(formatRemoteCli(response)).toEqual({ - stdout: `${JSON.stringify(response.result)}\n`, - stderr: '' - }) - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts deleted file mode 100644 index 40cbaf57f27b..000000000000 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { CliStatusResult } from '../../shared/runtime-types' -import type { RpcResponse } from '../runtime/rpc/core' -import { formatRemoteLinearCli } from './ssh-remote-linear-output' - -export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr: string } { - if (!response.ok) { - return { stdout: '', stderr: `${formatRemoteCliError(response.error)}\n` } - } - const result = response.result - if (isRecord(result) && 'app' in result && 'runtime' in result && 'graph' in result) { - const record = result as Record - return formatStatusResult(record as CliStatusResult) - } - const linear = formatRemoteLinearCli(result) - if (linear) { - return linear - } - return { stdout: `${JSON.stringify(result)}\n`, stderr: '' } -} - -function formatRemoteCliError(error: { message: string; data?: unknown }): string { - const nextSteps = - isRecord(error.data) && Array.isArray(error.data.nextSteps) - ? error.data.nextSteps.filter((step): step is string => typeof step === 'string') - : [] - if (nextSteps.length === 0) { - return error.message - } - return `${error.message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}` -} - -function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: string } { - return { - stdout: `${[ - `appRunning: ${status.app.running}`, - `pid: ${status.app.pid ?? 'none'}`, - `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, - `runtimeState: ${status.runtime.state}`, - `runtimeReachable: ${status.runtime.reachable}`, - `runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`, - `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, - `graphState: ${status.graph.state}` - ].join('\n')}\n`, - stderr: '' - } -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' -} diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts deleted file mode 100644 index 8ef28fd18e89..000000000000 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { EventEmitter } from 'node:events' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { - HostCliUnavailableError, - buildHostCliEnv, - resolveHostCliEntryPath, - resolveHostCliKillTimeoutMs, - runHostOrcaCliPassthrough -} from './ssh-remote-cli-host-passthrough' -import { resolveOrchestrationAskClientTimeoutMs } from '../../shared/orchestration-ask-timeout' -import { remoteCliRequestTimeoutMs } from '../../relay/remote-cli-timeout' -import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' -import { - ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV -} from '../../shared/orchestration-compatibility-evidence' -import { REMOTE_ARTIFACT_INPUT_ENV } from '../../shared/artifact-cli-bridge' -import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -type FakeChild = EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - stdin: { end: ReturnType; on: ReturnType } - kill: ReturnType -} - -function createFakeChild(): FakeChild { - const child = new EventEmitter() as FakeChild - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - child.stdin = { end: vi.fn(), on: vi.fn() } - child.kill = vi.fn() - return child -} - -const BASE_OPTIONS = { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true -} - -describe('resolveHostCliEntryPath', () => { - it('uses the in-repo entry for dev builds and the unpacked asar entry when packaged', () => { - expect( - resolveHostCliEntryPath({ isPackaged: false, resourcesPath: '/r', appPath: '/host/app' }) - ).toBe(join('/host/app', 'out', 'cli', 'index.js')) - expect( - resolveHostCliEntryPath({ isPackaged: true, resourcesPath: '/r', appPath: '/host/app' }) - ).toBe(join('/r', 'app.asar.unpacked', 'out', 'cli', 'index.js')) - }) -}) - -describe('buildHostCliEnv', () => { - it('forwards only Orca terminal-context vars from the remote env', () => { - const env = buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: { PATH: '/host/bin', NODE_OPTIONS: '--inspect' }, - remoteEnv: { - ORCA_TERMINAL_HANDLE: 'term_remote', - ORCA_WORKTREE_ID: 'repo::/home/alice/wt', - ORCA_PANE_KEY: 'pane-9', - ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret', - ORCA_WORKSPACE_ID: 'ws-1', - [ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]: 'wsl', - [ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]: 'caller-host', - [ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]: 'caller-incarnation', - [ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]: 'caller-attachment', - // Why: these are remote-machine paths and must not leak into the host - // subprocess (PATH would break host binary lookup; user-data would - // retarget the CLI at a different local instance). - PATH: '/remote/bin', - ORCA_USER_DATA_PATH: '/remote/user-data' - }, - userDataPath: '/host/user-data', - remoteCwd: '/home/alice/wt/sub', - runtimeAuthority: { - kind: 'ssh', - targetId: 'saved-target', - connectionIncarnation: 'connection-incarnation', - attachmentId: 'runtime-attachment' - } - }) - - expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') - expect(env.ORCA_WORKTREE_ID).toBe('repo::/home/alice/wt') - expect(env.ORCA_PANE_KEY).toBe('pane-9') - expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBe('launch-secret') - expect(env.ORCA_WORKSPACE_ID).toBe('ws-1') - expect(env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]).toBe('ssh') - expect(env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]).toBe('saved-target') - expect(env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]).toBe('connection-incarnation') - expect(env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]).toBe('runtime-attachment') - expect(env.PATH).toBe('/host/bin') - expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data') - expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub') - expect(env.ORCA_CLI_COMMAND).toBe('orca') - expect(env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(env.NODE_OPTIONS).toBeUndefined() - expect(env.ORCA_NODE_OPTIONS).toBe('--inspect') - }) - - it.each([ - ['dev host', { ORCA_DEV_REPO_ROOT: '/repo', ORCA_CLI_COMMAND: 'orca-dev' }], - ['packaged Linux host', { ORCA_CLI_COMMAND: 'orca-ide' }], - ['local host', {}], - ['WSL host', { WSL_DISTRO_NAME: 'Ubuntu', ORCA_CLI_COMMAND: 'orca-ide' }], - ['Windows host', { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }] - ])('pins %s recovery to the remote shim', (_name, hostEnv) => { - const env = buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv, - remoteEnv: { ORCA_CLI_COMMAND: 'untrusted-remote-command' }, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo' - }) - - expect(env.ORCA_CLI_COMMAND).toBe('orca') - }) - - it('stamps this runtime as the source and ignores remote or inherited stamps', () => { - const remoteSource = { - ORCA_RUNTIME_SOURCE_ID: 'remote-chosen-source', - ORCA_RUNTIME_SOURCE_INCARNATION: 'remote-chosen-runtime' - } - const build = (runtimeSource?: { sourceId: string; incarnation: string } | null) => - buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: { - ORCA_RUNTIME_SOURCE_ID: 'parent-source', - ORCA_RUNTIME_SOURCE_INCARNATION: 'parent-runtime' - }, - remoteEnv: remoteSource, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo', - runtimeSource - }) - - expect(build({ sourceId: 'this-source', incarnation: 'this-runtime' })).toMatchObject({ - ORCA_RUNTIME_SOURCE_ID: 'this-source', - ORCA_RUNTIME_SOURCE_INCARNATION: 'this-runtime', - ORCA_RUNTIME_SOURCE_PROFILE_PATH: '/host/user-data', - ORCA_USER_DATA_PATH: '/host/user-data' - }) - const unstamped = build(null) - expect(unstamped.ORCA_RUNTIME_SOURCE_ID).toBeUndefined() - expect(unstamped.ORCA_RUNTIME_SOURCE_INCARNATION).toBeUndefined() - }) - - it('never lets a remote command claim a local agent session', () => { - // The host's env carries a session id when Orca was launched inside a structured session; the - // remote shell's own is from another machine. Session identity is same-host only. - const env = buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: { - ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', - ORCA_STRUCTURED_SESSION: '1' - }, - remoteEnv: { - ORCA_TERMINAL_HANDLE: 'term_remote', - ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' - }, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo' - }) - - expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() - expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() - // The remote command still speaks as its own terminal. - expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') - }) - - it('namespaces identical remote artifact paths by stable SSH target', () => { - const artifactInput = { - sourceKey: '/srv/repo/report.html', - fileName: 'report.html', - contentType: 'text/html' as const - } - const build = (targetId: string) => - buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: {}, - remoteEnv: {}, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo', - runtimeAuthority: { - kind: 'ssh', - targetId, - connectionIncarnation: 'ephemeral-connection', - attachmentId: 'ephemeral-attachment' - }, - artifactInput - })[REMOTE_ARTIFACT_INPUT_ENV] - - const first = JSON.parse(String(build('host-a'))) - const second = JSON.parse(String(build('host-b'))) - expect(first.sourceKey).not.toBe(second.sourceKey) - expect(JSON.parse(first.sourceKey)).toEqual(['ssh', 'host-a', '/srv/repo/report.html']) - expect(JSON.parse(second.sourceKey)).toEqual(['ssh', 'host-b', '/srv/repo/report.html']) - expect(first.fileName).toBe('report.html') - }) -}) - -describe('resolveHostCliKillTimeoutMs', () => { - it.each([ - ['--wait-submit', '3600'], - ['--wait-submit=3600'], - ['--wait-submit=3600.000000000000001'], - ['--wait-submit', '1', '--wait-submit=3600'] - ])('keeps SSH prompt observation inside both outer deadlines: %j', (...waitFlags) => { - const argv = ['terminal', 'send', '--text', 'review', '--enter', ...waitFlags] - const innerTimeout = 3_600_000 + 10_000 - const hostTimeout = resolveHostCliKillTimeoutMs(argv) - const relayTimeout = remoteCliRequestTimeoutMs({ argv })! - expect(hostTimeout).toBeGreaterThan(innerTimeout) - expect(relayTimeout).toBeGreaterThan(hostTimeout) - }) - - it('keeps pre-command Enter flags inside the prompt observation deadline', () => { - const argv = ['--enter', 'terminal', 'send', '--text', 'review', '--wait-submit', '3600'] - const hostTimeout = resolveHostCliKillTimeoutMs(argv) - expect(hostTimeout).toBeGreaterThan(3_610_000) - expect(remoteCliRequestTimeoutMs({ argv })).toBeGreaterThan(hostTimeout) - }) - - it('extends the kill timer past an explicit --timeout-ms budget', () => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800000'])).toBe( - 1_920_000 - ) - expect(resolveHostCliKillTimeoutMs(['orchestration', 'check', '--timeout-ms=5000'])).toBe( - 600_000 - ) - expect(resolveHostCliKillTimeoutMs(['worktree', 'list'])).toBe(600_000) - }) - - it.each([ - [[], 720_000], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER)], 1_920_000], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER + 1)], 720_000], - [['--timeout-ms', '9007199254740991.1'], 720_000], - [['--timeout-ms', '1', '--timeout-ms=1800000'], 1_920_000], - [['--timeout-ms=1800000', '--timeout-ms', '1'], 600_000], - [['--timeout-ms', '1800000', '--timeout-ms'], 720_000], - [['--timeout-ms=1800000', '--timeout-ms='], 720_000], - [['--timeout-ms=1800000', '--timeout-ms', 'bad'], 720_000], - [['--timeout-ms', 'bad', '--timeout-ms=1800000'], 1_920_000], - [['--timeout-ms=bad', '--timeout-ms', '1800000'], 1_920_000] - ])('bounds ask child timers with last-wins flags %#', (timeoutArgs, expected) => { - expect(resolveHostCliKillTimeoutMs(['orchestration', '--json', 'ask', ...timeoutArgs])).toBe( - expected - ) - }) - - it('does not apply the ask maximum to other commands', () => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800001'])).toBe( - 1_920_001 - ) - }) - - it.each(['+1000000', '1000000.0', '1e6'])( - 'extends non-ask child timers using CLI-compatible integer syntax %s', - (raw) => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', raw])).toBe(1_120_000) - } - ) - - it.each([ - 'Infinity', - '1.5', - '-1', - 'bad', - String(Number.MAX_SAFE_INTEGER), - String(MAX_TIMER_DELAY_MS - 120_000 + 1) - ])('falls back to the default kill timer when a non-ask --timeout-ms %s is unusable', (raw) => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', raw])).toBe(600_000) - }) - - it('keeps the largest non-ask kill timer that stays inside the timer range', () => { - expect( - resolveHostCliKillTimeoutMs([ - 'terminal', - 'wait', - '--timeout-ms', - String(MAX_TIMER_DELAY_MS - 120_000) - ]) - ).toBe(MAX_TIMER_DELAY_MS) - }) - - it.each<[string[], number | undefined]>([ - [[], undefined], - [['--timeout-ms', '1'], 1], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER)], Number.MAX_SAFE_INTEGER], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER + 1)], undefined] - ])('keeps inner, host, and relay ask deadlines ordered %#', (timeoutArgs, parsedTimeout) => { - const argv = ['orchestration', 'ask', '--to', 'term_x', ...timeoutArgs] - const innerTimeout = resolveOrchestrationAskClientTimeoutMs(parsedTimeout) - const hostTimeout = resolveHostCliKillTimeoutMs(argv) - const relayTimeout = remoteCliRequestTimeoutMs({ argv }) - - expect(innerTimeout).toBeLessThan(hostTimeout) - expect(hostTimeout).toBeLessThan(relayTimeout!) - }) -}) - -describe('runHostOrcaCliPassthrough', () => { - it('spawns the bundled CLI entry with the remote argv and returns captured output', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['orchestration', 'task-create', '--spec', 'do the thing', '--json'], - cwd: '/home/alice/wt', - env: { ORCA_TERMINAL_HANDLE: 'term_remote' } - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.stdout.emit('data', Buffer.from('{"ok":true}\n')) - child.stderr.emit('data', Buffer.from('warn\n')) - child.emit('close', 0) - - const result = await resultPromise - expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: 'warn\n', exitCode: 0 }) - - expect(spawn).toHaveBeenCalledTimes(1) - const [execPath, args, options] = spawn.mock.calls[0] as unknown as [ - string, - string[], - { env: NodeJS.ProcessEnv } - ] - expect(execPath).toBe('/host/electron') - expect(args).toEqual([ - '/host/app/out/cli/index.js', - 'orchestration', - 'task-create', - '--spec', - 'do the thing', - '--json' - ]) - expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(options.env.ORCA_CLI_CWD).toBe('/home/alice/wt') - expect(options.env.ORCA_TERMINAL_HANDLE).toBe('term_remote') - // Why: stdin must be closed even without a payload so CLI handlers that - // stream stdin see EOF instead of hanging forever. - expect(child.stdin.end).toHaveBeenCalledWith() - }) - - it('pipes a stdin payload to the CLI subprocess', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['linear', 'comment', 'add', 'ENG-1', '--body-file', '-'], - cwd: '/home/alice/wt', - env: {}, - stdin: 'comment body' - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.emit('close', 0) - await resultPromise - - expect(child.stdin.end).toHaveBeenCalledWith('comment body') - }) - - it('propagates non-zero exit codes', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['worktree', 'show'], - cwd: '/', - env: {} - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.stderr.emit('data', Buffer.from('boom\n')) - child.emit('close', 3) - - await expect(resultPromise).resolves.toEqual({ stdout: '', stderr: 'boom\n', exitCode: 3 }) - }) - - it('throws HostCliUnavailableError when the CLI entry is missing', async () => { - const spawn = vi.fn() - await expect( - runHostOrcaCliPassthrough( - { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, - { ...BASE_OPTIONS, entryExists: () => false, spawn: spawn as never } - ) - ).rejects.toBeInstanceOf(HostCliUnavailableError) - expect(spawn).not.toHaveBeenCalled() - }) - - it('rejects an invalid injected kill timeout before spawning', async () => { - const spawn = vi.fn() - await expect( - runHostOrcaCliPassthrough( - { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, - { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 2_147_483_648 } - ) - ).rejects.toBeInstanceOf(RangeError) - expect(spawn).not.toHaveBeenCalled() - }) - - it('throws HostCliUnavailableError when the subprocess fails to launch', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.emit('error', new Error('spawn ENOENT')) - - await expect(resultPromise).rejects.toBeInstanceOf(HostCliUnavailableError) - }) - - it('kills the subprocess and reports an error when the kill timeout elapses', async () => { - vi.useFakeTimers() - try { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['terminal', 'wait', '--for', 'exit'], - cwd: '/', - env: {} - }, - { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 1000 } - ) - - await vi.advanceTimersByTimeAsync(1001) - const result = await resultPromise - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain('timed out') - } finally { - vi.useRealTimers() - } - }) - - it('caps runaway output instead of buffering it unbounded', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['terminal', 'read'], - cwd: '/', - env: {} - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - const chunk = Buffer.alloc(3 * 1024 * 1024, 97) - for (let i = 0; i < 4; i += 1) { - child.stdout.emit('data', chunk) - } - child.emit('close', 0) - - const result = await resultPromise - expect(result.stdout.length).toBeLessThanOrEqual(8 * 1024 * 1024 + 64) - expect(result.stdout).toContain('output truncated') - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts deleted file mode 100644 index 5e5eb59e2ab3..000000000000 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ /dev/null @@ -1,349 +0,0 @@ -// The SSH shim runs the bundled CLI so remote shells get the full command surface. -import { app } from 'electron' -import { spawn as nodeSpawn } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { getCanonicalUserDataPath } from '../persistence' -import { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' -export { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' -import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay' -import { - ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV -} from '../../shared/orchestration-compatibility-evidence' -import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' -import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' -import { stampRuntimeSourceEnv, type RuntimeSourceStamp } from '../../shared/runtime-source-env' -import { - sshBridgeCredentials, - type SshBridgeCallerScope, - type SshBridgeCredentialRegistry -} from '../runtime/rpc/ssh-bridge-credentials' -import { - REMOTE_ARTIFACT_INPUT_ENV, - sshArtifactSourceKey, - type RemoteArtifactInput -} from '../../shared/artifact-cli-bridge' - -export type SshCliRuntimeAuthority = { - kind: 'ssh' - targetId: string - connectionIncarnation: string - attachmentId: string -} - -export type RemoteOrcaCliRequest = { - argv: string[] - cwd: string - env: Record - stdin?: string - artifactInput?: RemoteArtifactInput - runtimeAuthority?: SshCliRuntimeAuthority - // Why: required so no bridged invocation can run with owner authority over this Orca. - callerScope: SshBridgeCallerScope -} - -export type RemoteOrcaCliResult = { - stdout: string - stderr: string - exitCode: number - postOutput?: RemoteOrcaCliPostOutput -} - -export type RemoteOrcaCliPostOutput = - | { - kind: 'legacy_check_ack' - terminal: string - messageIds: string[] - types?: string[] - } - | { - kind: 'legacy_question_ack' - terminal: string - questionId: string - answerMessageId: string - } - -export type HostCliPassthroughOptions = { - execPath?: string - cliEntryPath?: string - userDataPath?: string - hostEnv?: NodeJS.ProcessEnv - spawn?: typeof nodeSpawn - entryExists?: (path: string) => boolean - killTimeoutMs?: number - credentials?: SshBridgeCredentialRegistry - /** The runtime this bridge belongs to; the host CLI must reach exactly that one. */ - runtimeSource?: RuntimeSourceStamp | null -} - -/** Thrown when the host CLI entry cannot be launched at all; callers fall back - * to the legacy in-process command switch so previously-working commands keep - * working even on broken installs. */ -export class HostCliUnavailableError extends Error {} - -// Only terminal identity may cross hosts; remote paths and Node options cannot. -const REMOTE_CONTEXT_ENV_VARS = [ - 'ORCA_TERMINAL_HANDLE', - 'ORCA_WORKTREE_ID', - 'ORCA_PANE_KEY', - 'ORCA_AGENT_LAUNCH_TOKEN', - 'ORCA_WORKSPACE_ID' -] as const - -// Bound output retained for the relay response. -const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024 - -export function resolveHostCliEntryPath(app: { - isPackaged: boolean - resourcesPath: string - appPath: string -}): string { - // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev - // launcher in cli-installer.ts — packaged builds ship the CLI entry outside - // app.asar so Electron node mode can execute it directly. - return app.isPackaged - ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - : join(app.appPath, 'out', 'cli', 'index.js') -} - -export function buildHostCliEnv(args: { - hostEnv: NodeJS.ProcessEnv - remoteEnv: Record - userDataPath: string - remoteCwd: string - bridgeCredential: string - runtimeAuthority?: SshCliRuntimeAuthority - artifactInput?: RemoteArtifactInput - runtimeSource?: RuntimeSourceStamp | null -}): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...args.hostEnv } - for (const key of REMOTE_CONTEXT_ENV_VARS) { - const value = args.remoteEnv[key] - if (typeof value === 'string' && value.length > 0) { - env[key] = value - } - } - // Why: bind the subprocess to this app instance's runtime metadata (dev and - // parallel instances use non-default userData dirs). - env.ORCA_USER_DATA_PATH = args.userDataPath - // Why: the caller's working directory lives on the remote machine, so the - // subprocess cwd cannot be chdir'd there; ORCA_CLI_CWD carries it for - // cwd-based selectors like `--worktree active`. - env.ORCA_CLI_CWD = args.remoteCwd - // Why: recovery commands run on the SSH execution host through its relay shim. - env.ORCA_CLI_COMMAND = 'orca' - // Why: same node-mode hygiene as the shipped CLI launchers — stash and clear - // NODE_OPTIONS so Electron's node bootstrap does not inherit them. - env.ORCA_NODE_OPTIONS = args.hostEnv.NODE_OPTIONS ?? '' - env.ORCA_NODE_REPL_EXTERNAL_MODULE = args.hostEnv.NODE_REPL_EXTERNAL_MODULE ?? '' - delete env.NODE_OPTIONS - delete env.NODE_REPL_EXTERNAL_MODULE - delete env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] - delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] - delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] - delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] - delete env[REMOTE_ARTIFACT_INPUT_ENV] - // Why: a remote command must never claim a local agent session. The host's env carries one only - // when Orca was launched inside a session, and identity by session id is same-host only. - delete env[ORCA_AGENT_SESSION_ID_ENV] - delete env[ORCA_STRUCTURED_SESSION_ENV] - // Why: this machine's paired-server selection would route the remote command with its credentials. - delete env.ORCA_PAIRING_CODE - delete env.ORCA_REMOTE_PAIRING - delete env.ORCA_ENVIRONMENT - env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] = args.bridgeCredential - // Why: stamped from this process, never from the remote env, so remote input cannot pick the runtime. - // The host CLI dials exactly this profile, so the stamp names the same one. - stampRuntimeSourceEnv( - env, - args.runtimeSource ? { ...args.runtimeSource, profilePath: args.userDataPath } : null - ) - if (args.runtimeAuthority) { - env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' - env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId - env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] = - args.runtimeAuthority.connectionIncarnation - env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] = args.runtimeAuthority.attachmentId - } - if (args.artifactInput) { - const sourceKey = args.runtimeAuthority - ? sshArtifactSourceKey(args.runtimeAuthority.targetId, args.artifactInput.sourceKey) - : args.artifactInput.sourceKey - env[REMOTE_ARTIFACT_INPUT_ENV] = JSON.stringify({ ...args.artifactInput, sourceKey }) - } - env.ELECTRON_RUN_AS_NODE = '1' - return env -} - -export async function runHostOrcaCliPassthrough( - request: RemoteOrcaCliRequest, - options: HostCliPassthroughOptions = {} -): Promise { - // Why: per-field lazy defaults keep the module testable — tests inject all - // three, so no Electron API is touched outside the production path. - const execPath = options.execPath ?? process.execPath - let cliEntryPath: string - let userDataPath: string - try { - cliEntryPath = - options.cliEntryPath ?? - resolveHostCliEntryPath({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath() - }) - // Why: must match the userData dir the runtime RPC server writes metadata - // to (see index.ts OrcaRuntimeRpcServer wiring), or the CLI subprocess - // reports "Orca is not running" against a healthy app. - userDataPath = options.userDataPath ?? getCanonicalUserDataPath() - } catch (err) { - // Why: no Electron app context (or broken install paths) — degrade to the - // caller's legacy in-process fallback instead of failing the command. - throw new HostCliUnavailableError( - `Host CLI environment unavailable: ${err instanceof Error ? err.message : String(err)}` - ) - } - const hostEnv = options.hostEnv ?? process.env - const spawn = options.spawn ?? nodeSpawn - const entryExists = options.entryExists ?? existsSync - const killTimeoutMs = options.killTimeoutMs ?? resolveHostCliKillTimeoutMs(request.argv) - if (!isSafeTimerDelayMs(killTimeoutMs)) { - throw new RangeError( - `Host CLI kill timeout must be an integer between 0 and ${MAX_TIMER_DELAY_MS}ms.` - ) - } - - if (!entryExists(cliEntryPath)) { - throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`) - } - - const credential = (options.credentials ?? sshBridgeCredentials).mint(request.callerScope) - const env = buildHostCliEnv({ - hostEnv, - remoteEnv: request.env, - userDataPath, - remoteCwd: request.cwd, - bridgeCredential: credential.token, - runtimeAuthority: request.runtimeAuthority, - artifactInput: request.artifactInput, - runtimeSource: options.runtimeSource - }) - - try { - return await spawnHostCli(spawn, execPath, cliEntryPath, request, env, killTimeoutMs) - } finally { - credential.revoke() - } -} - -function spawnHostCli( - spawn: typeof nodeSpawn, - execPath: string, - cliEntryPath: string, - request: RemoteOrcaCliRequest, - env: NodeJS.ProcessEnv, - killTimeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - let settled = false - const child = spawn(execPath, [cliEntryPath, ...request.argv], { - env, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - - const stdout = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) - const stderr = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) - - const killTimer = setTimeout(() => { - if (settled) { - return - } - settled = true - try { - child.kill('SIGKILL') - } catch { - // best effort — process may already be gone - } - resolve({ - stdout: stdout.toString(), - stderr: `${stderr.toString()}Orca CLI bridge timed out after ${killTimeoutMs}ms on the host.\n`, - exitCode: 1 - }) - }, killTimeoutMs) - killTimer.unref?.() - - child.on('error', (err) => { - if (settled) { - return - } - settled = true - clearTimeout(killTimer) - // Why: failure to launch (ENOENT, EACCES) means the host CLI is not - // runnable at all — signal the caller to use the legacy fallback rather - // than reporting a confusing per-command failure. - reject( - new HostCliUnavailableError(`Failed to launch the Orca CLI on the host: ${err.message}`) - ) - }) - - child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)) - child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk)) - - child.on('close', (code) => { - if (settled) { - return - } - settled = true - clearTimeout(killTimer) - resolve({ - stdout: stdout.toString(), - stderr: stderr.toString(), - exitCode: typeof code === 'number' ? code : 1 - }) - }) - - if (child.stdin) { - child.stdin.on('error', () => { - // Why: the CLI may exit without draining stdin; EPIPE here is routine. - }) - if (request.stdin !== undefined) { - child.stdin.end(request.stdin) - } else { - child.stdin.end() - } - } - }) -} - -class CappedOutputCollector { - private readonly chunks: Buffer[] = [] - private bytes = 0 - private truncated = false - - constructor(private readonly maxBytes: number) {} - - push(chunk: Buffer): void { - if (this.truncated) { - return - } - const remaining = this.maxBytes - this.bytes - if (chunk.length >= remaining) { - this.chunks.push(chunk.subarray(0, remaining)) - this.bytes = this.maxBytes - this.truncated = true - return - } - this.chunks.push(chunk) - this.bytes += chunk.length - } - - toString(): string { - const text = Buffer.concat(this.chunks).toString('utf8') - return this.truncated ? `${text}\n[orca ssh cli] output truncated\n` : text - } -} diff --git a/src/main/ssh/ssh-remote-cli-in-process-result.ts b/src/main/ssh/ssh-remote-cli-in-process-result.ts deleted file mode 100644 index a35512e3451d..000000000000 --- a/src/main/ssh/ssh-remote-cli-in-process-result.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { RpcResponse } from '../runtime/rpc/core' -import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import type { RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' -import { formatRemoteCli } from './ssh-remote-cli-format' -import { - formatRemoteOrchestrationAsk, - getRemoteCliExitCode -} from './ssh-remote-orchestration-ask-output' -import { formatRemoteOrchestrationCheck } from './ssh-remote-orchestration-check-output' -import { getRemoteCliPostOutput } from './ssh-remote-orchestration-post-output' -import { resolveRemoteCliHandle } from './ssh-remote-cli-args' - -export function formatInProcessRemoteCliResult( - parsed: ParsedRemoteCli, - env: Record, - response: RpcResponse, - json: boolean -): RemoteOrcaCliResult { - const command = parsed.commandPath.join(' ') - const formatted = - command === 'orchestration check' - ? formatRemoteOrchestrationCheck( - response, - json, - resolveRemoteCliHandle(parsed.flags, env, 'terminal'), - parsed.flags.has('format') - ) - : command === 'orchestration ask' - ? formatRemoteOrchestrationAsk(response, json) - : json - ? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' } - : formatRemoteCli(response) - const postOutput = getRemoteCliPostOutput(parsed, env, response) - return { - stdout: formatted.stdout, - stderr: formatted.stderr, - exitCode: getRemoteCliExitCode(command, response), - ...(postOutput ? { postOutput } : {}) - } -} diff --git a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts deleted file mode 100644 index 44804c80b8ab..000000000000 --- a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -// Why: a missing CLI entry forces the legacy in-process bridge, the transport -// an SSH-hosted agent actually reaches `terminal list` through. -const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => false -} - -describe('remote CLI bridge terminal list', () => { - it('relays the execution host and scope to an SSH-hosted caller', async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [ - { - handle: 'term_remote', - ptyId: 'ssh:box-1@@pty-7', - worktreeId: 'repo-ssh::/remote/wt', - worktreePath: '/remote/wt', - branch: 'main', - tabId: 'tab-1', - leafId: 'leaf-1', - title: 'worker', - connected: true, - writable: true, - lastOutputAt: null, - preview: '', - executionHostId: 'ssh:box-1' - } - ], - totalCount: 1, - truncated: false, - hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } - }) - - const result = await runRemoteOrcaCli( - runtime, - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['terminal', 'list', '--json'], - cwd: '/home/alice/repo', - env: {} - }, - LEGACY_FALLBACK_OPTIONS - ) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - result: { - terminals: { executionHostId?: string }[] - hostScope?: { hostIds: string[]; omittedHostIds: string[] } - } - } - expect(payload.result.terminals[0]?.executionHostId).toBe('ssh:box-1') - expect(payload.result.hostScope).toEqual({ - hostIds: ['ssh:box-1'], - omittedHostIds: ['local'] - }) - }) -}) diff --git a/src/main/ssh/ssh-remote-directory-browse.ts b/src/main/ssh/ssh-remote-directory-browse.ts new file mode 100644 index 000000000000..b1fd912abbbd --- /dev/null +++ b/src/main/ssh/ssh-remote-directory-browse.ts @@ -0,0 +1,276 @@ +import type { SshConnectionManager } from './ssh-connection-manager' +import type { SshExecOptions } from './ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../shared/file-name-sort' +import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' + +export type RemoteDirEntry = { + name: string + isDirectory: boolean +} + +export type RemoteBrowseResult = { + entries: RemoteDirEntry[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor +} + +const SSH_BROWSE_TIMEOUT_MS = 15_000 + +// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one. +const POSIX_COMMAND_NOT_FOUND_EXIT = 127 + +// Carries the raw exit code so the fallback can distinguish 127 (no powershell.exe → not Windows) from a genuine PowerShell error. +class RemoteBrowseError extends Error { + constructor( + message: string, + readonly exitCode: number | null + ) { + super(message) + this.name = 'RemoteBrowseError' + } +} + +// Why: relay fs.readDir needs workspace-root ACLs that don't exist until a repo is added, so browse over raw SSH exec. +export async function browseSshDirectory( + manager: SshConnectionManager | null, + targetId: string, + dirPath: string +): Promise { + if (!manager) { + throw new Error('SSH connection manager not initialized') + } + const conn = manager.getConnection(targetId) + if (!conn) { + throw new Error(`SSH connection "${targetId}" not found`) + } + + try { + return await browseWithPosixShell(conn, dirPath) + } catch (posixError) { + // Why: only a RemoteBrowseError (ran, non-zero exit) signals a Windows shell; don't retry transport errors/timeouts as Windows. + if (!(posixError instanceof RemoteBrowseError)) { + throw posixError + } + try { + return await browseWithWindowsPowerShell(conn, dirPath) + } catch (fallbackError) { + // Why: exit 127 (no powershell.exe) → host isn't Windows, surface the original POSIX failure; otherwise PowerShell's own error is the real cause. + throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError + } + } +} + +type SshBrowseConnection = NonNullable> + +function browseWithPosixShell( + conn: SshBrowseConnection, + dirPath: string +): Promise { + // Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs. + return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix') +} + +function browseWithWindowsPowerShell( + conn: SshBrowseConnection, + dirPath: string +): Promise { + if (/^[\\/]+$/.test(dirPath.trim())) { + const script = [ + "$ErrorActionPreference = 'Stop'", + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + "Write-Output '/'", + "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }" + ].join('; ') + return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) + } + + const script = [ + "$ErrorActionPreference = 'Stop'", + // Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + `$dir = ${powerShellPathExpression(dirPath)}`, + 'Set-Location -LiteralPath $dir', + '$resolved = (Get-Location).ProviderPath', + // Why: the renderer's parentPath/joinPath split only on `/`, so emit a forward-slash resolvedPath while keeping native $resolved for Get-ChildItem. + "Write-Output ($resolved -replace '\\\\', '/')", + 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', + " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", + '}' + ].join('; ') + + return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) +} + +async function runBrowseCommand( + conn: SshBrowseConnection, + command: string, + pathFlavor: FilesystemPathFlavor, + options?: SshExecOptions +): Promise { + // Why: login-shell rc output would otherwise be read as the resolved path. + const fence = options?.wrapCommand === false ? null : buildPosixStdoutFence(command, 'SSH') + const remoteCommand = fence?.command ?? command + const channel = options ? await conn.exec(remoteCommand, options) : await conn.exec(remoteCommand) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let exitCode: number | null = null + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('exit', onExit) + channel.off('close', onClose) + channel.off('error', onError) + channel.stderr.off('error', onError) + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: no relay deadline exists during add-project browsing, so bound this raw exec channel or Add Remote Project hangs forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } + const resolveOnce = (result: RemoteBrowseResult): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const onStdoutData = (data: Buffer): void => { + stdout += data.toString() + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString() + } + // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. + const onExit = (code: number | null): void => { + exitCode = code + } + const onError = (error: Error): void => { + rejectOnce(error) + } + const onClose = (): void => { + stdout = fence?.readStdout(stdout) ?? stdout + // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + rejectOnce(new RemoteBrowseError(msg, exitCode)) + return + } + if (stderr.trim() && !stdout.trim()) { + rejectOnce(new Error(stderr.trim())) + return + } + + // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. + const lines = stdout.trim().split(/\r?\n/) + if (lines.length === 0) { + rejectOnce(new Error('Empty response from remote')) + return + } + + const resolvedPath = lines[0] + const entries: RemoteDirEntry[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line || line === './' || line === '../') { + continue + } + if (line.endsWith('/')) { + entries.push({ name: line.slice(0, -1), isDirectory: true }) + } else { + entries.push({ name: line, isDirectory: false }) + } + } + + // Sort: directories first, then natural name order (matches the Explorer) + sortDirEntries(entries) + + resolveOnce({ entries, resolvedPath, pathFlavor }) + } + + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('exit', onExit) + channel.on('close', onClose) + // Why: SSH exec streams emit `error` on transport loss; without a scoped listener a disappearing remote can become process-fatal. + channel.on('error', onError) + channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) +} + +// Why: exit 127 means powershell.exe wasn't found — the host isn't Windows, so surface the original POSIX failure instead. +function isPosixCommandNotFound(error: unknown): boolean { + return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT +} + +// Why: single-quote to block shell injection; ~ needs $HOME since single quotes suppress tilde expansion. +function shellEscape(s: string): string { + if (s === '~') { + return '"$HOME"' + } + if (s.startsWith('~/')) { + return `"$HOME"/${shellEscapeRaw(s.slice(2))}` + } + return shellEscapeRaw(s) +} + +function shellEscapeRaw(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'` +} + +function powerShellPathExpression(s: string): string { + if (s === '~') { + return '$HOME' + } + if (s.startsWith('~/') || s.startsWith('~\\')) { + return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` + } + return powerShellLiteral(normalizeWindowsDrivePath(s)) +} + +// Why: renderer's POSIX path rebuild yields '/C:/…' or bare 'C:' (drive-relative), both mis-resolved by Set-Location; re-root to a proper drive path. +function normalizeWindowsDrivePath(s: string): string { + const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') + return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped +} diff --git a/src/main/ssh/ssh-remote-linear-activity-output.test.ts b/src/main/ssh/ssh-remote-linear-activity-output.test.ts deleted file mode 100644 index 3cfb70c46867..000000000000 --- a/src/main/ssh/ssh-remote-linear-activity-output.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { LinearIssueContextResult } from '../../shared/linear/agent-access' -import { formatRemoteLinearCli } from './ssh-remote-linear-output' - -describe('SSH Linear activity output', () => { - it('shows requested activity in non-json issue output', () => { - const result: LinearIssueContextResult = { - issue: { - id: 'issue-1', - identifier: 'ENG-123', - title: 'Fix thing', - url: 'https://linear.app/acme/issue/ENG-123', - labels: [] - }, - activity: [{ id: 'history-1', actor: { kind: 'system' }, changes: [] }], - meta: { - requested: { - current: true, - include: { - comments: false, - children: false, - attachments: false, - relations: false, - activity: true - }, - depth: 2 - }, - resolved: { - id: 'issue-1', - identifier: 'ENG-123', - workspaceId: 'workspace-1', - workspaceName: 'Acme' - }, - partial: false, - includeErrors: [], - sections: { - activity: { returned: 1, cap: 250, capReached: false, hasMore: false } - } - } - } - - expect(formatRemoteLinearCli(result)?.stdout).toContain('Activity: 1') - }) -}) diff --git a/src/main/ssh/ssh-remote-linear-cli.test.ts b/src/main/ssh/ssh-remote-linear-cli.test.ts deleted file mode 100644 index 4efe8927cbde..000000000000 --- a/src/main/ssh/ssh-remote-linear-cli.test.ts +++ /dev/null @@ -1,877 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { isLinearProjectListResult } from './ssh-remote-linear-result-guards' -import { runControlGrantedRemoteOrcaCli as runRemoteOrcaCli } from './ssh-bridge-caller-scope.test-fixture' - -function createRuntime() { - const runtime = { - getRuntimeId: () => 'runtime-test', - getStatus: () => ({ - runtimeId: 'runtime-test', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: 1, - liveTabCount: 1, - liveLeafCount: 1 - }), - linearIssueContext: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - title: 'Fix thing', - url: 'https://linear.app/acme/issue/ENG-123', - labels: [{ id: 'label-1', name: 'Bug' }], - priority: 2, - estimate: 5, - dueDate: '2026-06-30' - }, - meta: { - requested: { - current: true, - depth: 2 - }, - resolved: { - id: 'issue-1', - identifier: 'ENG-123', - workspaceId: 'workspace-1', - workspaceName: 'Acme' - }, - partial: false, - includeErrors: [], - sections: {} - } - })), - linearSearchForAgents: vi.fn(async (request: unknown) => ({ - request, - issues: [], - meta: { - query: 'auth bug', - limit: 5, - returned: 0, - limitReached: false, - partial: false, - workspaceErrors: [] - } - })), - linearTeamListForAgents: vi.fn(async (request: unknown) => ({ - request, - teams: [ - { - id: 'team-1', - key: 'ENG', - name: 'Engineering', - workspace: { id: 'workspace-1', name: 'Acme' } - } - ], - meta: { workspaceId: 'workspace-1', returned: 1, partial: false, workspaceErrors: [] } - })), - linearTeamLabelsForAgents: vi.fn(async (request: unknown) => ({ - request, - team: { id: 'team-1', key: 'ENG', name: 'Engineering' }, - labels: [{ id: 'label-1', name: 'Bug', color: '#ff0000' }], - meta: { workspaceId: 'workspace-1', returned: 1 } - })), - linearProjectListForAgents: vi.fn(async (request: unknown) => ({ - request, - projects: [ - { - id: 'project-1', - name: 'Launch', - workspaceId: 'workspace-1', - workspaceName: 'Acme', - teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }] - } - ], - meta: { - query: 'launch', - workspaceId: 'workspace-1', - limit: 5, - returned: 1, - hasMore: false, - partial: false, - workspaceErrors: [] - } - })), - linearIssueListForAgents: vi.fn(async (request: unknown) => ({ - request, - issues: [], - meta: { - filter: 'open', - workspaceId: 'workspace-1', - limit: 5, - returned: 0, - hasMore: false, - partial: false, - workspaceErrors: [] - } - })), - linearIssueSetState: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - state: { id: 'state-review', name: 'In Review', type: 'started' }, - previousState: { id: 'state-started', name: 'In Progress' }, - meta: { workspaceId: 'workspace-1', alreadyInState: false } - })), - linearIssueUpdateTask: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - operation: 'priority', - previous: { - assignee: null, - priority: 0, - estimate: null, - dueDate: null, - labels: [] - }, - current: { - assignee: null, - priority: 2, - estimate: null, - dueDate: null, - labels: [] - }, - meta: { workspaceId: 'workspace-1', alreadySet: false } - })), - linearIssueAddComment: vi.fn(async (request: unknown) => ({ - request, - comment: { id: 'comment-1', url: null, parentId: null }, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - meta: { - workspaceId: 'workspace-1', - bodyChars: 4, - writeId: '123e4567-e89b-12d3-a456-426614174000', - deduplicated: false - } - })), - linearIssueAttachLink: vi.fn(async (request: unknown) => ({ - request, - attachment: { id: 'attachment-1', title: 'PR/MR link', url: 'https://example.com/review/1' }, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - meta: { - workspaceId: 'workspace-1', - writeId: '123e4567-e89b-12d3-a456-426614174000', - deduplicated: false - } - })), - linearIssueCreate: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-2', - identifier: 'ENG-456', - title: 'Follow-up', - url: 'https://linear.app/acme/issue/ENG-456', - team: { id: 'team-1', key: 'ENG', name: 'Engineering' }, - state: { id: 'state-triage', name: 'Triage' }, - parent: { id: 'issue-1', identifier: 'ENG-123' }, - project: { id: 'project-1', name: 'Launch' } - }, - meta: { - workspaceId: 'workspace-1', - writeId: '123e4567-e89b-12d3-a456-426614174000', - deduplicated: false - } - })) - } as unknown as OrcaRuntimeService - return runtime -} - -describe('runRemoteOrcaCli Linear commands', () => { - it('dispatches Linear issue reads through the remote runtime with SSH context hints', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--current', '--full', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { current: boolean; context: Record } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - current: true, - include: expect.objectContaining({ activity: true }), - context: { - remote: true, - terminalHandle: 'term_ssh', - worktreeId: 'repo::remote' - } - }) - expect(payload.result.request.context).not.toHaveProperty('cwd') - }) - - it('accepts leading boolean flags before SSH Linear commands', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['--json', 'linear', 'issue', 'ENG-123', '--full'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; include: Record } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - include: expect.objectContaining({ activity: true }) - }) - }) - - it('dispatches Linear search positional queries through the remote runtime', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth bug', '--limit', '5', '--workspace', 'all', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { query: string; limit: number; workspaceId: string } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toEqual({ - query: 'auth bug', - limit: 5, - workspaceId: 'all' - }) - }) - - it('dispatches Linear discovery and list reads through the remote runtime', async () => { - const runtime = createRuntime() - - const teamList = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'team', 'list', '--workspace', 'all', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const labels = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'team', 'labels', '--team', 'ENG', '--workspace', 'workspace-1', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const list = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'list', - '--filter', - 'open', - '--team', - 'ENG', - '--limit', - '5', - '--workspace', - 'workspace-1', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const projects = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'project', - 'list', - '--query', - 'launch', - '--limit', - '5', - '--workspace', - 'workspace-1', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(teamList.exitCode).toBe(0) - expect(labels.exitCode).toBe(0) - expect(list.exitCode).toBe(0) - expect(projects.exitCode).toBe(0) - expect( - (runtime as unknown as { linearTeamListForAgents: ReturnType }) - .linearTeamListForAgents - ).toHaveBeenCalledWith({ workspaceId: 'all' }) - expect( - (runtime as unknown as { linearTeamLabelsForAgents: ReturnType }) - .linearTeamLabelsForAgents - ).toHaveBeenCalledWith({ teamInput: 'ENG', workspaceId: 'workspace-1' }) - expect( - (runtime as unknown as { linearIssueListForAgents: ReturnType }) - .linearIssueListForAgents - ).toHaveBeenCalledWith({ - filter: 'open', - teamInput: 'ENG', - limit: 5, - workspaceId: 'workspace-1' - }) - expect( - (runtime as unknown as { linearProjectListForAgents: ReturnType }) - .linearProjectListForAgents - ).toHaveBeenCalledWith({ - query: 'launch', - limit: 5, - workspaceId: 'workspace-1' - }) - }) - - it('formats SSH Linear project list in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'project', 'list', '--query', 'launch', '--workspace', 'workspace-1'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('Launch') - expect(result.stdout).toContain('project-1') - expect(result.stdout).toContain('ENG') - expect(result.stdout).toContain('Acme') - expect(result.stderr).toBe('') - }) - - it('rejects malformed SSH Linear project list results before formatting', () => { - expect( - isLinearProjectListResult({ - projects: [{ id: 'project-1' }], - meta: { - limit: 5, - returned: 1, - hasMore: false, - partial: false, - workspaceErrors: [] - } - }) - ).toBe(false) - expect( - isLinearProjectListResult({ - projects: [{ id: 'project-1', name: 'Launch', teams: [{ id: 'team-1' }] }], - meta: { - limit: 5, - returned: 1, - hasMore: false, - partial: false, - workspaceErrors: [] - } - }) - ).toBe(false) - }) - - it('dispatches Linear status writes through the remote runtime with SSH context hints', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; to: string; context: Record } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - to: 'In Review', - context: { - remote: true, - terminalHandle: 'term_ssh', - worktreeId: 'repo::remote' - } - }) - }) - - it('dispatches Linear task-field writes through the SSH remote runtime', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'priority', 'set', 'ENG-123', '--to', 'high', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; operation: string; priority: number } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - operation: 'priority', - priority: 2 - }) - }) - - it('dispatches Linear creates with project input through the SSH remote runtime', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'create', - '--title', - 'Follow-up', - '--team', - 'ENG', - '--project', - 'project-1', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { title: string; teamInput: string; projectInput: string } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - title: 'Follow-up', - teamInput: 'ENG', - projectInput: 'project-1' - }) - }) - - it('formats SSH Linear creates with project input in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'create', '--title', 'Follow-up', '--team', 'ENG', '--project', 'project-1'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('Created ENG-456 under ENG-123 in Launch: Follow-up.\n') - expect(result.stderr).toBe('') - }) - - it('parses --me as a boolean for SSH Linear assignee writes', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'assignee', 'set', '--me', 'ENG-123', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; operation: string; assigneeMe: boolean } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - operation: 'assignee', - assigneeMe: true - }) - }) - - it('preserves repeated labels for SSH Linear label writes', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'label', - 'set', - 'ENG-123', - '--label', - 'label-1', - '--label', - 'label-2', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { operation: string; labelMode: string; labels: string[] } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - operation: 'labels', - labelMode: 'set', - labels: ['label-1', 'label-2'] - }) - }) - - it('formats SSH Linear writes in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('Added comment comment-1 to ENG-123.\n') - expect(result.stderr).toBe('') - }) - - it('dispatches body-file stdin writes in the SSH shim', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }, - stdin: 'line one\nline two\n' - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { body: string } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request.body).toBe('line one\nline two\n') - }) - - it('rejects body-file stdin writes when SSH stdin is unavailable', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'SSH Linear writes require stdin when using --body-file -.' - }) - }) - - it('rejects remote body-file paths in the SSH shim before dispatch', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', '--current', '--body-file', 'body.md', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'SSH Linear writes only support --body-file - for stdin.' - }) - }) - - it('formats SSH Linear issue reads in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--current'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('ENG-123 Fix thing') - expect(result.stdout).toContain('URL: https://linear.app/acme/issue/ENG-123') - expect(result.stdout).toContain('Priority: high') - expect(result.stdout).toContain('Estimate: 5') - expect(result.stdout).toContain('Labels: Bug') - expect(result.stdout).toContain('Due: 2026-06-30') - expect(result.stdout).not.toContain('"issue"') - }) - - it('prints SSH Linear search partial warnings to stderr in non-json mode', async () => { - const runtime = createRuntime() - const linearSearchForAgents = ( - runtime as unknown as { linearSearchForAgents: ReturnType } - ).linearSearchForAgents - linearSearchForAgents.mockResolvedValueOnce({ - issues: [], - meta: { - query: 'auth', - limit: 20, - returned: 0, - limitReached: false, - partial: true, - workspaceErrors: [ - { - workspace: { id: 'workspace-stale', name: 'Stale' }, - code: 'linear_network_error', - message: 'fetch failed' - } - ] - } - }) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('No Linear issues found.\n') - expect(result.stderr).toContain('warning: Stale unavailable for Linear search: fetch failed') - }) - - it('formats older SSH Linear search results without workspaceErrors in non-json mode', async () => { - const runtime = createRuntime() - const linearSearchForAgents = ( - runtime as unknown as { linearSearchForAgents: ReturnType } - ).linearSearchForAgents - linearSearchForAgents.mockResolvedValueOnce({ - issues: [], - meta: { - query: 'auth', - limit: 20, - returned: 0, - limitReached: false, - partial: false - } - }) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('No Linear issues found.\n') - expect(result.stderr).toBe('') - }) - - it('prints SSH Linear non-json failures to stderr instead of stdout', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - linearIssueContext.mockRejectedValueOnce(new Error('Linear is not connected.')) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--current'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('Linear is not connected.') - }) - - it('prints SSH Linear non-json next steps from structured errors', async () => { - const runtime = createRuntime() - const linearIssueAddComment = ( - runtime as unknown as { linearIssueAddComment: ReturnType } - ).linearIssueAddComment - linearIssueAddComment.mockRejectedValueOnce( - Object.assign(new Error('Linear may have applied the write.'), { - code: 'linear_write_unconfirmed', - data: { nextSteps: ['Retry once with the pinned command: `orca linear comment add`.'] } - }) - ) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('Linear may have applied the write.') - expect(result.stderr).toContain('Next step: Retry once with the pinned command') - }) - - it('shows SSH Linear command help without dispatching to the runtime', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--help'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('orca linear issue') - expect(result.stdout).toContain('Usage: orca linear issue') - expect(linearIssueContext).not.toHaveBeenCalled() - }) - - it('shows SSH Linear group help without dispatching to the runtime', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', '--help'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('orca linear') - expect(result.stdout).toContain('Usage: orca linear [options]') - expect(result.stdout).toContain('search') - expect(result.stdout).toContain('team list') - expect(result.stdout).toContain('label set') - expect(result.stdout).toContain('comment add') - expect(linearIssueContext).not.toHaveBeenCalled() - }) - - it('shows SSH Linear help through the local help command form', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - - const group = await runRemoteOrcaCli(runtime, { - argv: ['help', 'linear'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const issue = await runRemoteOrcaCli(runtime, { - argv: ['help', 'linear', 'issue'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(group.exitCode).toBe(0) - expect(group.stdout).toContain('Usage: orca linear [options]') - expect(issue.exitCode).toBe(0) - expect(issue.stdout).toContain('Usage: orca linear issue') - expect(linearIssueContext).not.toHaveBeenCalled() - }) - - it('rejects ambiguous Linear issue positional and flag ids in the remote shim', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', 'ENG-123', '--id', 'ENG-456', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'Pass --id either positionally or as a flag, not both.' - }) - }) - - it('rejects invalid Linear numeric flags in the remote shim', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth', '--limit', 'bad', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'Invalid numeric value for --limit' - }) - }) - - it('preserves Linear-specific JSON error codes for pre-dispatch remote shim validation', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', 'ENG-123', '--workspace', 'all', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'linear_invalid_workspace', - message: '--workspace all is not valid for issue' - }) - }) -}) diff --git a/src/main/ssh/ssh-remote-linear-cli.ts b/src/main/ssh/ssh-remote-linear-cli.ts deleted file mode 100644 index 4a5c5c4b4167..000000000000 --- a/src/main/ssh/ssh-remote-linear-cli.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import type { RpcResponse } from '../runtime/rpc/core' -import { getRemoteLinearReadHelp } from './ssh-remote-linear-read-help' -import { tryDispatchRemoteLinearReadCli } from './ssh-remote-linear-read-cli' -import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { - getRemoteLinearWriteHelp, - tryDispatchRemoteLinearWriteCli -} from './ssh-remote-linear-write-cli' - -export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null { - const helpPath = remoteLinearHelpPath(parsed) - if (!helpPath) { - return null - } - const readHelp = getRemoteLinearReadHelp(helpPath) - if (readHelp) { - return readHelp - } - return getRemoteLinearWriteHelp({ ...parsed, commandPath: helpPath }) -} - -function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null { - if (parsed.commandPath[0] === 'help' && parsed.commandPath[1] === 'linear') { - return parsed.commandPath.slice(1) - } - if (parsed.flags.has('help') && parsed.commandPath[0] === 'linear') { - return parsed.commandPath - } - return null -} - -export async function tryDispatchRemoteLinearCli( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli, - env: Record, - stdin?: string -): Promise { - const readResponse = await tryDispatchRemoteLinearReadCli(dispatcher, parsed, env) - if (readResponse) { - return readResponse - } - const writeResponse = await tryDispatchRemoteLinearWriteCli(dispatcher, parsed, env, stdin) - if (writeResponse) { - return writeResponse - } - return null -} diff --git a/src/main/ssh/ssh-remote-linear-list-issues.test.ts b/src/main/ssh/ssh-remote-linear-list-issues.test.ts deleted file mode 100644 index f7c66aee7e6a..000000000000 --- a/src/main/ssh/ssh-remote-linear-list-issues.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import { dispatchRemoteLinearListIssues } from './ssh-remote-linear-list-issues' - -describe('SSH Linear MCP-compatible issue listing', () => { - it('dispatches the same filter and pagination contract as the local CLI', async () => { - const dispatch = vi.fn().mockResolvedValue({ - id: 'response-1', - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - const dispatcher = { dispatch } as unknown as RpcDispatcher - - await dispatchRemoteLinearListIssues(dispatcher, { - commandPath: ['linear', 'list-issues'], - flags: new Map([ - ['team', 'ENG'], - ['cycle', 'Cycle 1'], - ['label', 'Bug'], - ['limit', '100'], - ['query', 'auth'], - ['state', 'In Progress'], - ['cursor', 'next-page'], - ['order-by', 'createdAt'], - ['project', 'Launch'], - ['release', 'v1'], - ['assignee', 'me'], - ['delegate', 'agent-1'], - ['parent-id', 'issue-parent'], - ['priority', '2'], - ['created-at', '-P30D'], - ['updated-at', '-P7D'], - ['include-archived', true], - ['workspace', 'workspace-1'], - ['json', true] - ]) - }) - - expect(dispatch).toHaveBeenCalledWith({ - id: expect.stringMatching(/^remote-cli-/), - authToken: 'remote-cli', - method: 'linear.mcpListIssues', - params: { - team: 'ENG', - cycle: 'Cycle 1', - label: 'Bug', - limit: 100, - query: 'auth', - state: 'In Progress', - cursor: 'next-page', - orderBy: 'createdAt', - project: 'Launch', - release: 'v1', - assignee: 'me', - delegate: 'agent-1', - parentId: 'issue-parent', - priority: 2, - createdAt: '-P30D', - updatedAt: '-P7D', - includeArchived: true, - workspaceId: 'workspace-1' - } - }) - }) - - it('rejects zero limits before RPC dispatch like the local CLI', async () => { - const dispatch = vi.fn() - const dispatcher = { dispatch } as unknown as RpcDispatcher - - await expect( - dispatchRemoteLinearListIssues(dispatcher, { - commandPath: ['linear', 'list-issues'], - flags: new Map([['limit', '0']]) - }) - ).rejects.toMatchObject({ - code: 'invalid_argument', - message: 'Invalid positive integer for --limit' - }) - expect(dispatch).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-remote-linear-list-issues.ts b/src/main/ssh/ssh-remote-linear-list-issues.ts deleted file mode 100644 index 98f10e8f36c7..000000000000 --- a/src/main/ssh/ssh-remote-linear-list-issues.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { LinearMcpIssueListRequest } from '../../shared/linear/agent-access' -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import type { RpcResponse } from '../runtime/rpc/core' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { LINEAR_MCP_ISSUE_LIST_FLAGS } from './ssh-remote-linear-read-flags' - -export async function dispatchRemoteLinearListIssues( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli -): Promise { - for (const flag of parsed.flags.keys()) { - if (!LINEAR_MCP_ISSUE_LIST_FLAGS.has(flag)) { - throw new RemoteCliArgumentError('invalid_argument', `Unknown flag: --${flag}`) - } - } - if (parsed.commandPath.length !== 2) { - throw new RemoteCliArgumentError('invalid_argument', 'list-issues does not accept positionals') - } - const priority = optionalInteger(parsed.flags, 'priority') - if (priority !== undefined && (priority < 0 || priority > 4)) { - throw new RemoteCliArgumentError('invalid_argument', '--priority must be between 0 and 4') - } - const limit = optionalPositiveInteger(parsed.flags, 'limit') - const request: LinearMcpIssueListRequest = { - team: optionalString(parsed.flags, 'team'), - cycle: optionalString(parsed.flags, 'cycle'), - label: optionalString(parsed.flags, 'label'), - limit, - query: optionalString(parsed.flags, 'query'), - state: optionalString(parsed.flags, 'state'), - cursor: optionalString(parsed.flags, 'cursor'), - orderBy: orderBy(parsed.flags), - project: optionalString(parsed.flags, 'project'), - release: optionalString(parsed.flags, 'release'), - assignee: optionalString(parsed.flags, 'assignee'), - delegate: optionalString(parsed.flags, 'delegate'), - parentId: optionalString(parsed.flags, 'parent-id'), - priority, - createdAt: optionalString(parsed.flags, 'created-at'), - updatedAt: optionalString(parsed.flags, 'updated-at'), - includeArchived: parsed.flags.get('include-archived') === true, - workspaceId: optionalString(parsed.flags, 'workspace') - } - return await dispatcher.dispatch({ - id: `remote-cli-${Date.now()}`, - authToken: 'remote-cli', - method: 'linear.mcpListIssues', - params: request - }) -} - -function optionalString(flags: Map, name: string): string | undefined { - const value = flags.get(name) - return typeof value === 'string' && value ? value : undefined -} - -function optionalInteger(flags: Map, name: string): number | undefined { - const raw = optionalString(flags, name) - if (raw === undefined) { - return undefined - } - const value = Number(raw) - if (!Number.isInteger(value) || value < 0) { - throw new RemoteCliArgumentError('invalid_argument', `--${name} must be an integer`) - } - return value -} - -function optionalPositiveInteger( - flags: Map, - name: string -): number | undefined { - const raw = optionalString(flags, name) - if (raw === undefined) { - return undefined - } - const value = Number(raw) - if (!Number.isInteger(value) || value <= 0) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid positive integer for --${name}`) - } - return value -} - -function orderBy(flags: Map): LinearMcpIssueListRequest['orderBy'] { - const value = optionalString(flags, 'order-by') - if (value === undefined || value === 'createdAt' || value === 'updatedAt') { - return value - } - throw new RemoteCliArgumentError('invalid_argument', '--order-by must be createdAt or updatedAt') -} diff --git a/src/main/ssh/ssh-remote-linear-output.ts b/src/main/ssh/ssh-remote-linear-output.ts deleted file mode 100644 index 533e743d10ef..000000000000 --- a/src/main/ssh/ssh-remote-linear-output.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { - LinearIssueContextResult, - LinearIssueListResult, - LinearMcpIssueListResult, - LinearIssueTaskUpdateResult, - LinearIssueRelationWriteResult, - LinearProjectListResult, - LinearSearchIssueSummary, - LinearSearchResult, - LinearTeamListResult, - LinearTeamLabelsResult, - LinearTeamMembersResult, - LinearTeamStatesResult, - LinearStatusSetResult, - LinearCommentAddResult, - LinearAttachResult, - LinearCreateResult, - LinearSaveIssueResult -} from '../../shared/linear/agent-access' -import { appendLinearListTruncation } from '../../shared/linear/list-truncation-format' -import { linearPriorityLabel } from '../../shared/linear/priority-label' -import { - formatLinearProjectListRows, - linearProjectListWarningLines -} from '../../shared/linear/project-list-format' -import { - isLinearAttachResult, - isLinearCommentAddResult, - isLinearCreateResult, - isLinearSaveIssueResult, - isLinearIssueContextResult, - isLinearIssueListResult, - isLinearMcpIssueListResult, - isLinearProjectListResult, - isLinearSearchResult, - isLinearStatusSetResult, - isLinearTaskUpdateResult, - isLinearRelationWriteResult, - isLinearTeamLabelsResult, - isLinearTeamListResult, - isLinearTeamMembersResult, - isLinearTeamStatesResult -} from './ssh-remote-linear-result-guards' - -export function formatRemoteLinearCli(result: unknown): { stdout: string; stderr: string } | null { - if (isLinearIssueContextResult(result)) { - return { stdout: `${formatLinearIssue(result)}\n`, stderr: linearIssueWarnings(result) } - } - if (isLinearSearchResult(result)) { - return { - stdout: `${appendLinearListTruncation( - formatLinearIssueRows(result.issues), - result.issues.length, - result.truncated ?? result.meta.limitReached - )}\n`, - stderr: linearListWarnings(result, 'Linear search') - } - } - if (isLinearMcpIssueListResult(result)) { - return { - stdout: `${appendLinearListTruncation( - formatLinearIssueRows(result.issues), - result.issues.length, - result.truncated ?? result.meta.hasMore - )}\n`, - stderr: linearMcpListWarnings(result) - } - } - if (isLinearIssueListResult(result)) { - return { - stdout: `${appendLinearListTruncation( - formatLinearIssueRows(result.issues), - result.issues.length, - result.truncated ?? result.meta.hasMore - )}\n`, - stderr: linearListWarnings(result) - } - } - if (isLinearProjectListResult(result)) { - return { - stdout: `${formatLinearProjectListRows(result)}\n`, - stderr: linearProjectListWarnings(result) - } - } - if (isLinearTeamListResult(result)) { - return { stdout: `${formatLinearTeamList(result)}\n`, stderr: linearListWarnings(result) } - } - if (isLinearTeamMembersResult(result)) { - return { stdout: `${formatLinearTeamMembers(result)}\n`, stderr: '' } - } - if (isLinearTeamStatesResult(result)) { - return { stdout: `${formatLinearTeamStates(result)}\n`, stderr: '' } - } - if (isLinearTeamLabelsResult(result)) { - return { stdout: `${formatLinearTeamLabels(result)}\n`, stderr: '' } - } - if (isLinearStatusSetResult(result)) { - return { stdout: `${formatLinearStatusSet(result)}\n`, stderr: '' } - } - if (isLinearTaskUpdateResult(result)) { - return { stdout: `${formatLinearTaskUpdate(result)}\n`, stderr: '' } - } - if (isLinearRelationWriteResult(result)) { - return { stdout: `${formatLinearRelationWrite(result)}\n`, stderr: '' } - } - if (isLinearCommentAddResult(result)) { - return { stdout: `${formatLinearCommentAdd(result)}\n`, stderr: '' } - } - if (isLinearAttachResult(result)) { - return { stdout: `${formatLinearAttach(result)}\n`, stderr: '' } - } - if (isLinearSaveIssueResult(result)) { - return { stdout: `${formatLinearSaveIssue(result)}\n`, stderr: '' } - } - if (isLinearCreateResult(result)) { - return { stdout: `${formatLinearCreate(result)}\n`, stderr: '' } - } - return null -} - -function formatLinearIssue(result: LinearIssueContextResult): string { - const issue = result.issue - const lines = [ - `${issue.identifier} ${issue.title}`, - `URL: ${issue.url}`, - `State: ${issue.state?.name ?? 'unknown'}`, - `Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`, - `Project: ${issue.project?.name ?? 'none'}` - ] - lines.push(`Priority: ${formatPriority(issue.priority)}`) - lines.push(`Estimate: ${issue.estimate ?? 'none'}`) - if (issue.labels.length > 0) { - lines.push( - `Labels: ${issue.labels - .map((label) => label.name) - .filter(Boolean) - .join(', ')}` - ) - } - if (issue.dueDate) { - lines.push(`Due: ${issue.dueDate}`) - } - for (const section of ['comments', 'children', 'attachments', 'relations', 'activity'] as const) { - const meta = result.meta.sections[section] - if (meta) { - lines.push(`${section[0].toUpperCase()}${section.slice(1)}: ${meta.returned}`) - } - } - if (result.inlineMedia?.length) { - lines.push(`Inline media: ${result.inlineMedia.length} (use --json for URLs)`) - } - return lines.join('\n') -} - -function formatLinearSaveIssue(result: LinearSaveIssueResult): string { - return result.meta.created - ? formatLinearCreate(result) - : `Saved ${result.issue.identifier}: ${result.issue.title}.` -} - -function formatLinearIssueRows(issues: LinearSearchIssueSummary[]): string { - if (issues.length === 0) { - return 'No Linear issues found.' - } - return issues.map(formatLinearIssueRow).join('\n') -} - -function formatLinearIssueRow(issue: LinearSearchIssueSummary): string { - const state = issue.state?.name ?? 'unknown' - const assignee = issue.assignee?.displayName ?? 'unassigned' - return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}` -} - -function formatPriority(priority: number | null | undefined): string { - return linearPriorityLabel(priority) -} - -function formatLinearTeamList(result: LinearTeamListResult): string { - if (result.teams.length === 0) { - return 'No Linear teams found.' - } - return result.teams - .map( - (team) => - `${team.key.padEnd(10)} ${team.name}${team.workspace ? ` ${team.workspace.name}` : ''}` - ) - .join('\n') -} - -function formatLinearTeamMembers(result: LinearTeamMembersResult): string { - if (result.members.length === 0) { - return `No Linear members found for ${result.team.key}.` - } - return result.members - .map((member) => `${(member.displayName ?? 'unknown').padEnd(24)} ${member.id ?? ''}`) - .join('\n') -} - -function formatLinearTeamStates(result: LinearTeamStatesResult): string { - if (result.states.length === 0) { - return `No Linear workflow states found for ${result.team.key}.` - } - return result.states - .map((state) => `${state.name.padEnd(24)} ${(state.type ?? '').padEnd(12)} ${state.id}`) - .join('\n') -} - -function formatLinearTeamLabels(result: LinearTeamLabelsResult): string { - if (result.labels.length === 0) { - return `No Linear labels found for ${result.team.key}.` - } - return result.labels.map((label) => `${label.name.padEnd(24)} ${label.id}`).join('\n') -} - -function formatLinearStatusSet(result: LinearStatusSetResult): string { - const suffix = result.meta.alreadyInState ? ' (already set)' : '' - return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.` -} - -function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string { - const suffix = result.meta.alreadySet ? ' (already set)' : '' - return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.` -} - -function formatLinearRelationWrite(result: LinearIssueRelationWriteResult): string { - const verb = result.operation === 'add' ? 'Added' : 'Removed' - const suffix = result.meta.alreadySet - ? result.operation === 'add' - ? ' (already present)' - : ' (already absent)' - : '' - return `${verb} ${result.issue.identifier} ${result.relation.relationship} ${result.relatedIssue.identifier}${suffix}.` -} - -function formatLinearCommentAdd(result: LinearCommentAddResult): string { - const suffix = result.meta.deduplicated ? ' (already posted)' : '' - return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.` -} - -function formatLinearAttach(result: LinearAttachResult): string { - const suffix = result.meta.deduplicated ? ' (already attached)' : '' - return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.` -} - -function formatLinearCreate(result: LinearCreateResult | LinearSaveIssueResult): string { - const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : '' - const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : '' - const suffix = result.meta.deduplicated ? ' (already created)' : '' - return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.` -} - -function taskOperationLabel(operation: LinearIssueTaskUpdateResult['operation']): string { - return operation === 'dueDate' ? 'due date' : operation -} - -function linearIssueWarnings(result: LinearIssueContextResult): string { - const warnings = result.meta.includeErrors.map( - (error) => `warning: ${error.include} unavailable: ${error.message}` - ) - for (const [name, meta] of Object.entries(result.meta.sections)) { - if (meta?.capReached) { - warnings.push(`warning: ${name} capped at ${meta.returned}/${meta.cap}`) - } - } - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} - -function linearListWarnings( - result: LinearSearchResult | LinearIssueListResult | LinearTeamListResult, - label = 'Linear' -): string { - const warnings: string[] = [] - const meta = result.meta - if ('hasMore' in meta && meta.hasMore) { - warnings.push(`warning: showing first ${meta.returned} Linear issues`) - } - if ('limitReached' in meta && meta.limitReached) { - warnings.push(`warning: showing first ${meta.returned} Linear issues`) - } - for (const error of meta.workspaceErrors ?? []) { - warnings.push(`warning: ${error.workspace.name} unavailable for ${label}: ${error.message}`) - } - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} - -function linearMcpListWarnings(result: LinearMcpIssueListResult): string { - const warnings = result.meta.workspaceErrors.map( - (error) => `warning: ${error.workspace.name} unavailable for Linear: ${error.message}` - ) - if (result.meta.hasMore) { - warnings.unshift( - `warning: more results available; next cursor: ${result.meta.nextCursor ?? 'n/a'}` - ) - } - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} - -function linearProjectListWarnings(result: LinearProjectListResult): string { - const warnings = linearProjectListWarningLines(result) - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} diff --git a/src/main/ssh/ssh-remote-linear-read-cli.ts b/src/main/ssh/ssh-remote-linear-read-cli.ts deleted file mode 100644 index d9ecfb273655..000000000000 --- a/src/main/ssh/ssh-remote-linear-read-cli.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { - LINEAR_CHILDREN_MAX_DEPTH, - clampLinearIssueDepth, - clampLinearSearchLimit, - type LinearIssueInclude -} from '../../shared/linear/agent-access' -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import type { RpcResponse } from '../runtime/rpc/core' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { dispatchRemoteLinearListIssues } from './ssh-remote-linear-list-issues' - -import { - LINEAR_ISSUE_FLAGS, - LINEAR_LIST_FLAGS, - LINEAR_PROJECT_LIST_FLAGS, - LINEAR_SEARCH_FLAGS, - LINEAR_TEAM_LIST_FLAGS, - LINEAR_TEAM_LOOKUP_FLAGS -} from './ssh-remote-linear-read-flags' - -export async function tryDispatchRemoteLinearReadCli( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli, - env: Record -): Promise { - if (isRemoteCommand(parsed, 'linear', 'list-issues')) { - return await dispatchRemoteLinearListIssues(dispatcher, parsed) - } - if (isRemoteCommand(parsed, 'linear', 'issue')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'issue'], - allowedFlags: LINEAR_ISSUE_FLAGS, - positionalFlag: 'id', - maxPositionals: 1 - }) - return await call(dispatcher, 'linear.issueContext', buildRemoteLinearIssueRequest(parsed, env)) - } - if (isRemoteCommand(parsed, 'linear', 'search')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'search'], - allowedFlags: LINEAR_SEARCH_FLAGS, - positionalFlag: 'query', - maxPositionals: 1 - }) - return await call(dispatcher, 'linear.agentSearchIssues', { - query: remotePositional(parsed, 2) ?? requiredString(parsed.flags, 'query'), - limit: clampLinearSearchLimit(optionalPositiveInteger(parsed.flags, 'limit')), - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'list')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'team', 'list'], - allowedFlags: LINEAR_TEAM_LIST_FLAGS, - positionalFlag: 'id', - maxPositionals: 0 - }) - return await call(dispatcher, 'linear.agentTeamList', { - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'members')) { - return await dispatchRemoteLinearTeamLookup( - dispatcher, - parsed, - ['linear', 'team', 'members'], - 'linear.agentTeamMembers' - ) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'states')) { - return await dispatchRemoteLinearTeamLookup( - dispatcher, - parsed, - ['linear', 'team', 'states'], - 'linear.agentTeamStates' - ) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'labels')) { - return await dispatchRemoteLinearTeamLookup( - dispatcher, - parsed, - ['linear', 'team', 'labels'], - 'linear.agentTeamLabels' - ) - } - if (isRemoteCommand(parsed, 'linear', 'project', 'list')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'project', 'list'], - allowedFlags: LINEAR_PROJECT_LIST_FLAGS, - positionalFlag: 'id', - maxPositionals: 0 - }) - return await call(dispatcher, 'linear.agentProjectList', { - query: optionalString(parsed.flags, 'query'), - limit: clampLinearSearchLimit(optionalPositiveInteger(parsed.flags, 'limit')), - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - if (isRemoteCommand(parsed, 'linear', 'list')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'list'], - allowedFlags: LINEAR_LIST_FLAGS, - positionalFlag: 'id', - maxPositionals: 0 - }) - return await call(dispatcher, 'linear.agentIssueList', { - filter: linearListFilter(parsed.flags), - teamInput: optionalString(parsed.flags, 'team'), - limit: optionalPositiveInteger(parsed.flags, 'limit'), - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - return null -} - -async function dispatchRemoteLinearTeamLookup( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli, - command: string[], - method: string -): Promise { - validateLinearRemoteArgs(parsed, { - command, - allowedFlags: LINEAR_TEAM_LOOKUP_FLAGS, - positionalFlag: 'team', - maxPositionals: 0 - }) - return await call(dispatcher, method, { - teamInput: requiredString(parsed.flags, 'team'), - workspaceId: optionalString(parsed.flags, 'workspace') - }) -} - -function validateLinearRemoteArgs( - parsed: ParsedRemoteCli, - options: { - command: string[] - allowedFlags: ReadonlySet - positionalFlag: string - maxPositionals: number - } -): void { - for (const flag of parsed.flags.keys()) { - if (!options.allowedFlags.has(flag)) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Unknown flag --${flag} for command: ${options.command.join(' ')}` - ) - } - } - - const positionals = parsed.commandPath.slice(options.command.length) - if (positionals.length > options.maxPositionals) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Unknown command: ${parsed.commandPath.join(' ')}` - ) - } - if (positionals.length > 0 && parsed.flags.has(options.positionalFlag)) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Pass --${options.positionalFlag} either positionally or as a flag, not both.` - ) - } -} - -function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean { - return command.every((part, index) => parsed.commandPath[index] === part) -} - -function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined { - const value = parsed.commandPath.slice(startIndex).join(' ').trim() - return value || undefined -} - -function buildRemoteLinearIssueRequest( - parsed: ParsedRemoteCli, - env: Record -): Record { - const full = parsed.flags.get('full') === true - const include: Record = { - comments: full || parsed.flags.get('comments') === true, - children: full || parsed.flags.get('children') === true, - attachments: full || parsed.flags.get('attachments') === true, - relations: full || parsed.flags.get('relations') === true, - activity: full || parsed.flags.get('activity') === true - } - if (parsed.flags.has('depth') && !include.children) { - throw new RemoteCliArgumentError('invalid_argument', '--depth requires --children or --full') - } - const requestedDepth = optionalNonNegativeInteger(parsed.flags, 'depth') - if (requestedDepth !== undefined && requestedDepth > LINEAR_CHILDREN_MAX_DEPTH) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `--depth must be at most ${LINEAR_CHILDREN_MAX_DEPTH}` - ) - } - const workspaceId = optionalString(parsed.flags, 'workspace') - if (workspaceId === 'all') { - throw new RemoteCliArgumentError( - 'linear_invalid_workspace', - '--workspace all is not valid for issue' - ) - } - const input = optionalString(parsed.flags, 'id') ?? remotePositional(parsed, 2) - return { - input, - current: input ? false : parsed.flags.get('current') === true, - workspaceId, - include, - depth: clampLinearIssueDepth(requestedDepth), - context: { - remote: true, - ...(env.ORCA_WORKTREE_ID ? { worktreeId: env.ORCA_WORKTREE_ID } : {}), - ...(env.ORCA_TERMINAL_HANDLE ? { terminalHandle: env.ORCA_TERMINAL_HANDLE } : {}) - } - } -} - -async function call( - dispatcher: RpcDispatcher, - method: string, - params?: Record -): Promise { - return await dispatcher.dispatch({ - id: `remote-cli-${Date.now()}`, - authToken: 'remote-cli', - method, - params - }) -} - -function requiredString(flags: Map, name: string): string { - const value = optionalString(flags, name) - if (!value) { - throw new RemoteCliArgumentError('invalid_argument', `Missing --${name}`) - } - return value -} - -function optionalString(flags: Map, name: string): string | undefined { - const value = flags.get(name) - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function optionalNumber(flags: Map, name: string): number | undefined { - const value = optionalString(flags, name) - if (value === undefined) { - return undefined - } - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`) - } - return parsed -} - -function optionalPositiveInteger( - flags: Map, - name: string -): number | undefined { - const value = optionalNumber(flags, name) - if (value === undefined) { - return undefined - } - if (!Number.isInteger(value) || value <= 0) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid positive integer for --${name}`) - } - return value -} - -function linearListFilter( - flags: Map -): 'assigned' | 'created' | 'all' | 'completed' | 'open' | undefined { - const filter = optionalString(flags, 'filter') - if (filter === undefined) { - return undefined - } - if (['assigned', 'created', 'all', 'completed', 'open'].includes(filter)) { - return filter as 'assigned' | 'created' | 'all' | 'completed' | 'open' - } - throw new RemoteCliArgumentError( - 'invalid_argument', - '--filter must be assigned, created, all, completed, or open' - ) -} - -function optionalNonNegativeInteger( - flags: Map, - name: string -): number | undefined { - const value = optionalNumber(flags, name) - if (value === undefined) { - return undefined - } - if (!Number.isInteger(value) || value < 0) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Invalid non-negative integer for --${name}` - ) - } - return value -} diff --git a/src/main/ssh/ssh-remote-linear-read-flags.ts b/src/main/ssh/ssh-remote-linear-read-flags.ts deleted file mode 100644 index 08eea74c43d2..000000000000 --- a/src/main/ssh/ssh-remote-linear-read-flags.ts +++ /dev/null @@ -1,83 +0,0 @@ -export const LINEAR_ISSUE_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'current', - 'comments', - 'children', - 'depth', - 'attachments', - 'relations', - 'activity', - 'full', - 'workspace', - 'id' -]) -export const LINEAR_SEARCH_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'limit', - 'workspace', - 'query' -]) -export const LINEAR_TEAM_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'workspace' -]) -export const LINEAR_TEAM_LOOKUP_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'team', - 'workspace' -]) -export const LINEAR_PROJECT_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'query', - 'limit', - 'workspace' -]) -export const LINEAR_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'filter', - 'team', - 'limit', - 'workspace' -]) -export const LINEAR_MCP_ISSUE_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'team', - 'cycle', - 'label', - 'limit', - 'query', - 'state', - 'cursor', - 'order-by', - 'project', - 'release', - 'assignee', - 'delegate', - 'parent-id', - 'priority', - 'created-at', - 'updated-at', - 'include-archived', - 'workspace' -]) diff --git a/src/main/ssh/ssh-remote-linear-read-help.ts b/src/main/ssh/ssh-remote-linear-read-help.ts deleted file mode 100644 index da7948f4e9df..000000000000 --- a/src/main/ssh/ssh-remote-linear-read-help.ts +++ /dev/null @@ -1,163 +0,0 @@ -export function getRemoteLinearReadHelp(commandPath: string[]): string | null { - if (commandPath.length === 1 && commandPath[0] === 'linear') { - return LINEAR_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'issue')) { - return LINEAR_ISSUE_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'list-issues')) { - return LINEAR_MCP_ISSUE_LIST_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'search')) { - return LINEAR_SEARCH_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'list')) { - return LINEAR_TEAM_LIST_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'members')) { - return LINEAR_TEAM_MEMBERS_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'states')) { - return LINEAR_TEAM_STATES_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'labels')) { - return LINEAR_TEAM_LABELS_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'project', 'list')) { - return LINEAR_PROJECT_LIST_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'list')) { - return LINEAR_LIST_HELP - } - return null -} - -function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean { - return ( - commandPath.length === command.length && - command.every((part, index) => commandPath[index] === part) - ) -} - -const LINEAR_HELP = `orca linear - -Usage: orca linear [options] - -Commands: - save-issue Create or update a Linear issue - list-issues List Linear issues with MCP-compatible filters - relation add Add a Linear issue relation - relation remove Remove a Linear issue relation - issue Read Linear issue context for agents - search Search connected Linear workspaces - team list List connected Linear teams - team members List Linear team members - team states List Linear team workflow states - team labels List Linear team labels - project list List connected Linear projects - list List Linear issues - assignee set Set a Linear issue assignee - assignee clear Clear a Linear issue assignee - priority set Set a Linear issue priority - priority clear Clear a Linear issue priority - estimate set Set a Linear issue estimate - estimate clear Clear a Linear issue estimate - due-date set Set a Linear issue due date - due-date clear Clear a Linear issue due date - label add Add labels to a Linear issue - label remove Remove labels from a Linear issue - label set Replace labels on a Linear issue - status set Set a Linear issue status - comment add Add a comment to a Linear issue - attach Attach a link to a Linear issue - create Create a Linear issue - -Run \`orca linear --help\` for command-specific usage.` - -const LINEAR_ISSUE_HELP = `orca linear issue - -Usage: orca linear issue [] [--current] [--comments] [--children] [--depth ] [--attachments] [--relations] [--activity] [--full] [--workspace ] [--json] - -Read Linear issue context for agents - -Options: - --help Show this help message - --json Emit machine-readable JSON - --pairing-code - --environment - --current Use the current Orca worktree linked Linear issue - --comments Include threaded Linear comments - --children Include recursive child issues - --depth Child issue depth for --children/--full - --attachments Include attachment metadata and URLs - --relations Include blocking, related, and duplicate links - --activity Include issue field-change history - --full Include all supported V1 issue context within caps - --workspace Connected Linear workspace id - --id Linear issue key, id, or URL - -Examples: - $ orca linear issue ENG-123 - $ orca linear issue --current --comments - $ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json` - -const LINEAR_MCP_ISSUE_LIST_HELP = `orca linear list-issues - -Usage: orca linear list-issues [--team ] [--cycle ] [--label